feat: mejorar gestión de stock y crear modal para subcategorías en bloque

This commit is contained in:
Juan Felipe Zapata Moreno 2026-03-02 13:12:29 -06:00
parent fccb425781
commit cfd990ae0a
8 changed files with 215 additions and 11 deletions

View File

@ -142,7 +142,7 @@ const remove = () => {
<!-- Mensaje para productos con decimales --> <!-- Mensaje para productos con decimales -->
<div v-else-if="item.allows_decimals && item.unit_of_measure" class="mb-2"> <div v-else-if="item.allows_decimals && item.unit_of_measure" class="mb-2">
<p class="text-xs text-gray-500 dark:text-gray-400"> <p class="text-xs text-gray-500 dark:text-gray-400">
{{ item.unit_of_measure.name }} ({{ item.unit_of_measure.abbreviation }}) - Permite decimales {{ item.unit_of_measure.name }} ({{ item.unit_of_measure.abbreviation }})
</p> </p>
</div> </div>

View File

@ -14,8 +14,9 @@ const props = defineProps({
const emit = defineEmits(['add-to-cart']); const emit = defineEmits(['add-to-cart']);
/** Computados */ /** Computados */
const isLowStock = computed(() => props.product?.stock < 10); const availableStock = computed(() => props.product?.main_warehouse_stock ?? props.product?.stock ?? 0);
const isOutOfStock = computed(() => props.product?.stock <= 0); const isLowStock = computed(() => availableStock.value < 10);
const isOutOfStock = computed(() => availableStock.value <= 0);
const formattedPrice = computed(() => { const formattedPrice = computed(() => {
const price = props.product?.price?.retail_price || 0; const price = props.product?.price?.retail_price || 0;
@ -55,7 +56,7 @@ const handleAddToCart = () => {
'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400': !isLowStock && !isOutOfStock 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400': !isLowStock && !isOutOfStock
}" }"
> >
{{ isOutOfStock ? 'Sin stock' : `Stock: ${product.stock}` }} {{ isOutOfStock ? 'Sin stock' : `Stock: ${availableStock}` }}
</span> </span>
</div> </div>

View File

@ -0,0 +1,186 @@
<script setup>
import { ref } from 'vue';
import { useForm, apiURL } from '@Services/Api';
import Modal from '@Holos/Modal.vue';
import FormInput from '@Holos/Form/Input.vue';
import FormError from '@Holos/Form/Elements/Error.vue';
/** Eventos */
const emit = defineEmits(['close', 'created']);
/** Propiedades */
const props = defineProps({
show: Boolean,
categoryId: {
type: [String, Number],
required: true
}
});
/** Estado: lista de subcategorías a crear */
const emptyEntry = () => ({ name: '', description: '', is_active: true });
const entries = ref([emptyEntry()]);
const addEntry = () => entries.value.push(emptyEntry());
const removeEntry = (index) => {
if (entries.value.length > 1) {
entries.value.splice(index, 1);
}
};
/** Formulario */
const form = useForm({});
/** Métodos */
const createSubcategory = () => {
const payload = entries.value.length === 1
? entries.value[0]
: entries.value;
form.transform(() => payload).post(apiURL(`categorias/${props.categoryId}/subcategorias`), {
onSuccess: (data) => {
Notify.success(
entries.value.length === 1
? 'Subcategoría creada exitosamente'
: 'Subcategorías creadas exitosamente'
);
emit('created', data?.models ?? data?.model);
closeModal();
},
onError: () => {
Notify.error('Error al crear la subcategoría');
}
});
};
const closeModal = () => {
entries.value = [emptyEntry()];
form.reset();
emit('close');
};
</script>
<template>
<Modal :show="show" max-width="md" @close="closeModal">
<div class="p-6">
<!-- Header -->
<div class="flex items-center justify-between mb-6">
<h3 class="text-lg font-bold text-gray-900 dark:text-gray-100">
Crear Subcategoría
</h3>
<button
@click="closeModal"
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
<!-- Formulario -->
<form @submit.prevent="createSubcategory" class="space-y-4">
<!-- Entradas dinámicas -->
<div
v-for="(entry, index) in entries"
:key="index"
class="space-y-3"
:class="{ 'border-t border-gray-200 dark:border-gray-700 pt-4': index > 0 }"
>
<div v-if="entries.length > 1" class="flex items-center justify-between">
<span class="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase">
Subcategoría {{ index + 1 }}
</span>
<button
type="button"
@click="removeEntry(index)"
class="text-red-400 hover:text-red-600 transition-colors"
title="Eliminar entrada"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
<!-- Nombre -->
<div>
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
NOMBRE
</label>
<FormInput
v-model="entry.name"
type="text"
placeholder="Nombre de la subcategoría"
required
/>
<FormError :message="form.errors?.[`${index}.name`] ?? form.errors?.name" />
</div>
<!-- Descripción -->
<div>
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
DESCRIPCIÓN
</label>
<textarea
v-model="entry.description"
rows="2"
class="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-100"
placeholder="Descripción de la subcategoría"
></textarea>
<FormError :message="form.errors?.[`${index}.description`] ?? form.errors?.description" />
</div>
<!-- Estado -->
<div>
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
ESTADO
</label>
<select
v-model="entry.is_active"
class="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-100"
>
<option :value="true">Activo</option>
<option :value="false">Inactivo</option>
</select>
<FormError :message="form.errors?.[`${index}.is_active`] ?? form.errors?.is_active" />
</div>
</div>
<!-- Agregar otra -->
<button
type="button"
@click="addEntry"
class="flex items-center gap-1.5 text-sm font-medium text-indigo-600 hover:text-indigo-800 dark:text-indigo-400 dark:hover:text-indigo-300 transition-colors"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/>
</svg>
Agregar otra subcategoría
</button>
<!-- Botones -->
<div class="flex items-center justify-end gap-3 mt-6">
<button
type="button"
@click="closeModal"
class="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 transition-colors"
>
Cancelar
</button>
<button
type="submit"
:disabled="form.processing"
class="px-4 py-2 text-sm font-semibold text-white bg-gray-900 rounded-lg hover:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-900 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
<span v-if="form.processing">Guardando...</span>
<span v-else>Guardar</span>
</button>
</div>
</form>
</div>
</Modal>
</template>

View File

@ -29,7 +29,8 @@ const createSubcategory = () => {
form.post(apiURL(`categorias/${props.categoryId}/subcategorias`), { form.post(apiURL(`categorias/${props.categoryId}/subcategorias`), {
onSuccess: (data) => { onSuccess: (data) => {
Notify.success('Subcategoría creada exitosamente'); Notify.success('Subcategoría creada exitosamente');
emit('created', data?.model); const created = data?.model ?? (data?.models ? data.models[0] : []);
emit('created', created);
closeModal(); closeModal();
}, },
onError: () => { onError: () => {

View File

@ -5,7 +5,7 @@ import { useSearcher, apiURL } from '@Services/Api';
import Table from '@Holos/Table.vue'; import Table from '@Holos/Table.vue';
import GoogleIcon from '@Shared/GoogleIcon.vue'; import GoogleIcon from '@Shared/GoogleIcon.vue';
import CreateModal from './CreateModal.vue'; import CreateModal from './CreateBulkModal.vue';
import EditModal from './EditModal.vue'; import EditModal from './EditModal.vue';
import DeleteModal from './DeleteModal.vue'; import DeleteModal from './DeleteModal.vue';

View File

@ -521,6 +521,7 @@ watch(() => props.show, (isShown) => {
<select <select
v-model="form.origin_warehouse_id" v-model="form.origin_warehouse_id"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed"
disabled
required required
> >
<option value="">Seleccionar almacén...</option> <option value="">Seleccionar almacén...</option>

View File

@ -52,6 +52,7 @@ const showUnitEquivalenceSelector = ref(false);
const unitEquivalenceSelectorProduct = ref(null); const unitEquivalenceSelectorProduct = ref(null);
const unitEquivalenceSelectorData = ref({ equivalences: [], baseUnit: null }); const unitEquivalenceSelectorData = ref({ equivalences: [], baseUnit: null });
const equivalencesCache = ref({}); const equivalencesCache = ref({});
const mainWarehouseId = ref(null);
/** Buscador de productos */ /** Buscador de productos */
const searcher = useSearcher({ const searcher = useSearcher({
@ -59,6 +60,9 @@ const searcher = useSearcher({
onSuccess: (r) => { onSuccess: (r) => {
products.value = r.products?.data || []; products.value = r.products?.data || [];
productsMeta.value = r.products || null; productsMeta.value = r.products || null;
if (r.main_warehouse_id && !mainWarehouseId.value) {
mainWarehouseId.value = r.main_warehouse_id;
}
}, },
onError: () => { onError: () => {
products.value = []; products.value = [];
@ -82,7 +86,15 @@ const bundleSearcher = useSearcher({
}); });
/** Métodos de búsqueda */ /** Métodos de búsqueda */
const doSearch = () => searcher.search(searchQuery.value); const doSearch = () => {
searcher.query = searchQuery.value;
searcher.load({
url: mainWarehouseId.value
? apiURL(`inventario/warehouse/${mainWarehouseId.value}`)
: apiURL('inventario'),
filters: {}
});
};
const doBundleSearch = () => bundleSearcher.search(searchQuery.value); const doBundleSearch = () => bundleSearcher.search(searchQuery.value);
/** Métodos */ /** Métodos */
@ -332,7 +344,8 @@ const handleCodeDetected = async (barcode) => {
if (productResult.data?.products?.data?.length > 0) { if (productResult.data?.products?.data?.length > 0) {
const product = productResult.data.products.data[0]; const product = productResult.data.products.data[0];
if (product.stock <= 0) { const availableStock = product.main_warehouse_stock ?? product.stock;
if (availableStock <= 0) {
window.Notify.warning(`El producto "${product.name}" no tiene stock disponible`); window.Notify.warning(`El producto "${product.name}" no tiene stock disponible`);
return; return;
} }

View File

@ -57,7 +57,8 @@ const useCart = defineStore('cart', {
} }
// Si NO tiene seriales, incrementar normalmente // Si NO tiene seriales, incrementar normalmente
if (existingItem.quantity < product.stock) { const stockLimit = product.main_warehouse_stock ?? product.stock;
if (existingItem.quantity < stockLimit) {
existingItem.quantity++; existingItem.quantity++;
} else { } else {
window.Notify.warning('No hay suficiente stock disponible'); window.Notify.warning('No hay suficiente stock disponible');
@ -69,7 +70,8 @@ const useCart = defineStore('cart', {
: parseFloat(product.price?.retail_price || 0); : parseFloat(product.price?.retail_price || 0);
const conversionFactor = parseFloat(config?.conversion_factor || 1); const conversionFactor = parseFloat(config?.conversion_factor || 1);
const maxStock = conversionFactor !== 1 ? Math.floor(product.stock / conversionFactor) : product.stock; const baseStock = product.main_warehouse_stock ?? product.stock;
const maxStock = conversionFactor !== 1 ? Math.floor(baseStock / conversionFactor) : baseStock;
// Agregar nuevo item // Agregar nuevo item
this.items.push({ this.items.push({
@ -159,7 +161,7 @@ const useCart = defineStore('cart', {
quantity: quantity, quantity: quantity,
unit_price: parseFloat(product.price?.retail_price || 0), unit_price: parseFloat(product.price?.retail_price || 0),
tax_rate: parseFloat(product.price?.tax || 16), tax_rate: parseFloat(product.price?.tax || 16),
max_stock: product.stock, max_stock: product.main_warehouse_stock ?? product.stock,
track_serials: product.track_serials, track_serials: product.track_serials,
serial_numbers: newSerials, serial_numbers: newSerials,
// Campos para unidad de medida // Campos para unidad de medida