#49 feat: diálogo para vincular productos con proveedores en Gestión de Productos

This commit is contained in:
2026-08-15 17:45:34 -05:00
parent 760b3031b2
commit 9917d4866f
4 changed files with 231 additions and 0 deletions

View File

@@ -31,6 +31,10 @@ module.exports = {
'!src/components/PublicOrderSummary.vue', '!src/components/PublicOrderSummary.vue',
'!src/components/order/*.vue', '!src/components/order/*.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',
'!src/pages/pedido/**/*.vue', '!src/pages/pedido/**/*.vue',
], ],

View File

@@ -122,6 +122,19 @@
</v-chip> </v-chip>
</template> </template>
<!-- Slot para columna de proveedores -->
<template #item.actions="{ item }">
<v-btn
@click="openSupplierLink(item)"
color="primary"
size="small"
variant="tonal"
prepend-icon="mdi-truck"
>
Proveedores
</v-btn>
</template>
<!-- Loading state --> <!-- Loading state -->
<template #loading> <template #loading>
<v-skeleton-loader type="table-row@10"></v-skeleton-loader> <v-skeleton-loader type="table-row@10"></v-skeleton-loader>
@@ -138,6 +151,13 @@
</v-col> </v-col>
</v-row> </v-row>
<!-- Diálogo de proveedores del producto -->
<SupplierLinkDialog
:visible="linkDialog"
:product="linkProduct"
@update:visible="linkDialog = $event"
/>
<!-- Snackbar de feedback --> <!-- Snackbar de feedback -->
<v-snackbar <v-snackbar
v-model="snackbar.show" v-model="snackbar.show"
@@ -155,6 +175,7 @@
<script setup> <script setup>
import { ref, watch, inject, onMounted, computed } from "vue"; import { ref, watch, inject, onMounted, computed } from "vue";
import SupplierLinkDialog from "@/components/provenance/admin/SupplierLinkDialog.vue";
// Estado // Estado
const api = inject("api"); const api = inject("api");
@@ -164,6 +185,8 @@ const selected = ref([]);
const loading = ref(false); const loading = ref(false);
const snackbar = ref({ show: false, message: "", color: "success" }); const snackbar = ref({ show: false, message: "", color: "success" });
const searchQuery = ref(""); const searchQuery = ref("");
const linkDialog = ref(false);
const linkProduct = ref(null);
// Headers de la tabla // Headers de la tabla
const headers = [ const headers = [
@@ -171,6 +194,7 @@ const headers = [
{ title: "Nombre", key: "name", sortable: true }, { title: "Nombre", key: "name", sortable: true },
{ title: "Precio", key: "price", sortable: true }, { title: "Precio", key: "price", sortable: true },
{ title: "Estado", key: "active", sortable: true }, { title: "Estado", key: "active", sortable: true },
{ title: "Proveedores", key: "actions", sortable: false },
]; ];
// Computed - Productos filtrados por búsqueda // Computed - Productos filtrados por búsqueda
@@ -236,6 +260,11 @@ function showSnackbar(message, color) {
snackbar.value = { show: true, message, color }; snackbar.value = { show: true, message, color };
} }
function openSupplierLink(product) {
linkProduct.value = product;
linkDialog.value = true;
}
// Watchers // Watchers
watch(activeFilter, () => { watch(activeFilter, () => {
selected.value = []; selected.value = [];

View File

@@ -0,0 +1,126 @@
<template>
<v-dialog
max-width="560"
:model-value="visible"
@update:model-value="onUpdateVisible"
>
<v-card>
<v-card-title>Proveedores del producto</v-card-title>
<v-card-text>
<p v-if="product" class="text-subtitle-1 mb-3">
{{ product.name }}
</p>
<v-autocomplete
v-model="selectedIds"
chips
closable-chips
item-title="name"
item-value="id"
:items="supplierItems"
label="Proveedores"
multiple
:search-input="searchQuery"
/>
<v-chip
v-if="selectedIds.length > 0"
class="mt-3"
color="primary"
variant="tonal"
>
Seleccionados ({{ selectedIds.length }})
</v-chip>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="close">Cancelar</v-btn>
<v-btn
color="primary"
data-testid="supplier-link-save"
:disabled="saving"
variant="elevated"
@click="save"
>
Guardar
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script setup>
import { computed, inject, ref, watch } from 'vue'
const props = defineProps({
visible: {
type: Boolean,
default: false,
},
product: {
type: Object,
default: null,
},
})
const emit = defineEmits(['update:visible'])
const api = inject('api')
const suppliers = ref([])
const selectedIds = ref([])
const searchQuery = ref('')
const saving = ref(false)
const supplierItems = computed(() => {
const query = searchQuery.value.trim().toLowerCase()
let filtered = suppliers.value
if (query) {
filtered = suppliers.value.filter(supplier => {
return (supplier.name || '').toLowerCase().includes(query)
})
}
const selected = selectedIds.value.map(Number)
for (const supplier of suppliers.value) {
const isSelected = selected.includes(supplier.id)
const alreadyIncluded = filtered.some(item => item.id === supplier.id)
if (isSelected && !alreadyIncluded) filtered = [supplier, ...filtered]
}
return filtered
})
async function load () {
suppliers.value = await api.getSuppliers()
if (props.product) {
const detail = await api.getProduct(props.product.id)
selectedIds.value = detail.suppliers || []
} else {
selectedIds.value = []
}
}
watch(
() => props.visible,
async isVisible => {
if (isVisible) await load()
},
{ immediate: true }
)
async function save () {
saving.value = true
try {
await api.updateProduct(props.product.id, { suppliers: selectedIds.value })
close()
} catch (error) {
console.error('Error al guardar proveedores del producto:', error)
} finally {
saving.value = false
}
}
function onUpdateVisible (value) {
emit('update:visible', value)
}
function close () {
emit('update:visible', false)
}
</script>

View File

@@ -0,0 +1,72 @@
import { describe, expect, it, vi } from 'vitest'
import { flushPromises, mount } from '@vue/test-utils'
import SupplierLinkDialog from '@/components/provenance/admin/SupplierLinkDialog.vue'
import vuetify from '@/plugins/vuetify'
import { clickBody } from './helpers'
const suppliers = [
{ id: 5, name: 'Asociación Agropecuaria La Mesa' },
{ id: 9, name: 'Finca El Paraíso' },
{ id: 12, name: 'Cooperativa del Valle' },
]
function mockApi () {
return {
getSuppliers: vi.fn().mockResolvedValue(suppliers),
getProduct: vi.fn().mockResolvedValue({ id: 1, name: 'Panela regional por Kg', suppliers: [5] }),
updateProduct: vi.fn().mockResolvedValue({}),
}
}
function mountDialog (api, props = {}) {
return mount(SupplierLinkDialog, {
props: { visible: true, product: { id: 1, name: 'Panela regional por Kg' }, ...props },
global: { plugins: [vuetify], provide: { api } },
})
}
describe('SupplierLinkDialog', () => {
it('carga los proveedores y los suppliers actuales del producto al abrir', async () => {
const api = mockApi()
mountDialog(api)
await flushPromises()
expect(api.getSuppliers).toHaveBeenCalled()
expect(api.getProduct).toHaveBeenCalledWith(1)
expect(document.body.textContent).toContain('Panela regional por Kg')
})
it('permite cambiar la selección y guarda los proveedores', async () => {
const api = mockApi()
const wrapper = mountDialog(api)
await flushPromises()
const autocomplete = wrapper.findAllComponents({ name: 'VAutocomplete' })
.find(component => component.props('items').some(item => item.id === 5))
await autocomplete.vm.$emit('update:modelValue', [5, 9])
expect(document.body.textContent).toContain('Seleccionados (2)')
clickBody('[data-testid="supplier-link-save"]')
await flushPromises()
expect(api.updateProduct).toHaveBeenCalledWith(1, { suppliers: [5, 9] })
})
it('mantiene los proveedores seleccionados aunque el filtro no los incluya', async () => {
const api = mockApi()
const wrapper = mountDialog(api)
await flushPromises()
const autocomplete = wrapper.findAllComponents({ name: 'VAutocomplete' })
.find(component => component.props('items').some(item => item.id === 5))
await autocomplete.vm.$emit('update:modelValue', [5, 12])
await autocomplete.vm.$emit('update:search', 'Finca')
const items = autocomplete.props('items')
expect(items.some(item => item.id === 5)).toBe(true)
expect(items.some(item => item.id === 12)).toBe(true)
expect(items.some(item => item.id === 9)).toBe(true)
expect(items.some(item => item.id === 7)).toBe(false)
})
})