#49 feat: diálogo para vincular productos con proveedores en Gestión de Productos
This commit is contained in:
@@ -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',
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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 = [];
|
||||||
|
|||||||
126
src/components/provenance/admin/SupplierLinkDialog.vue
Normal file
126
src/components/provenance/admin/SupplierLinkDialog.vue
Normal 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>
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user