feat: mejorar gestión de stock y crear modal para subcategorías en bloque
This commit is contained in:
parent
fccb425781
commit
cfd990ae0a
@ -142,7 +142,7 @@ const remove = () => {
|
||||
<!-- Mensaje para productos con decimales -->
|
||||
<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">
|
||||
{{ item.unit_of_measure.name }} ({{ item.unit_of_measure.abbreviation }}) - Permite decimales
|
||||
{{ item.unit_of_measure.name }} ({{ item.unit_of_measure.abbreviation }})
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@ -14,8 +14,9 @@ const props = defineProps({
|
||||
const emit = defineEmits(['add-to-cart']);
|
||||
|
||||
/** Computados */
|
||||
const isLowStock = computed(() => props.product?.stock < 10);
|
||||
const isOutOfStock = computed(() => props.product?.stock <= 0);
|
||||
const availableStock = computed(() => props.product?.main_warehouse_stock ?? props.product?.stock ?? 0);
|
||||
const isLowStock = computed(() => availableStock.value < 10);
|
||||
const isOutOfStock = computed(() => availableStock.value <= 0);
|
||||
|
||||
const formattedPrice = computed(() => {
|
||||
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
|
||||
}"
|
||||
>
|
||||
{{ isOutOfStock ? 'Sin stock' : `Stock: ${product.stock}` }}
|
||||
{{ isOutOfStock ? 'Sin stock' : `Stock: ${availableStock}` }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
|
||||
186
src/pages/POS/Category/Subcategories/CreateBulkModal.vue
Normal file
186
src/pages/POS/Category/Subcategories/CreateBulkModal.vue
Normal 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>
|
||||
@ -29,7 +29,8 @@ const createSubcategory = () => {
|
||||
form.post(apiURL(`categorias/${props.categoryId}/subcategorias`), {
|
||||
onSuccess: (data) => {
|
||||
Notify.success('Subcategoría creada exitosamente');
|
||||
emit('created', data?.model);
|
||||
const created = data?.model ?? (data?.models ? data.models[0] : []);
|
||||
emit('created', created);
|
||||
closeModal();
|
||||
},
|
||||
onError: () => {
|
||||
|
||||
@ -5,7 +5,7 @@ import { useSearcher, apiURL } from '@Services/Api';
|
||||
|
||||
import Table from '@Holos/Table.vue';
|
||||
import GoogleIcon from '@Shared/GoogleIcon.vue';
|
||||
import CreateModal from './CreateModal.vue';
|
||||
import CreateModal from './CreateBulkModal.vue';
|
||||
import EditModal from './EditModal.vue';
|
||||
import DeleteModal from './DeleteModal.vue';
|
||||
|
||||
|
||||
@ -521,6 +521,7 @@ watch(() => props.show, (isShown) => {
|
||||
<select
|
||||
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"
|
||||
disabled
|
||||
required
|
||||
>
|
||||
<option value="">Seleccionar almacén...</option>
|
||||
|
||||
@ -52,6 +52,7 @@ const showUnitEquivalenceSelector = ref(false);
|
||||
const unitEquivalenceSelectorProduct = ref(null);
|
||||
const unitEquivalenceSelectorData = ref({ equivalences: [], baseUnit: null });
|
||||
const equivalencesCache = ref({});
|
||||
const mainWarehouseId = ref(null);
|
||||
|
||||
/** Buscador de productos */
|
||||
const searcher = useSearcher({
|
||||
@ -59,6 +60,9 @@ const searcher = useSearcher({
|
||||
onSuccess: (r) => {
|
||||
products.value = r.products?.data || [];
|
||||
productsMeta.value = r.products || null;
|
||||
if (r.main_warehouse_id && !mainWarehouseId.value) {
|
||||
mainWarehouseId.value = r.main_warehouse_id;
|
||||
}
|
||||
},
|
||||
onError: () => {
|
||||
products.value = [];
|
||||
@ -82,7 +86,15 @@ const bundleSearcher = useSearcher({
|
||||
});
|
||||
|
||||
/** 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);
|
||||
|
||||
/** Métodos */
|
||||
@ -332,7 +344,8 @@ const handleCodeDetected = async (barcode) => {
|
||||
if (productResult.data?.products?.data?.length > 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`);
|
||||
return;
|
||||
}
|
||||
|
||||
@ -57,7 +57,8 @@ const useCart = defineStore('cart', {
|
||||
}
|
||||
|
||||
// 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++;
|
||||
} else {
|
||||
window.Notify.warning('No hay suficiente stock disponible');
|
||||
@ -69,7 +70,8 @@ const useCart = defineStore('cart', {
|
||||
: parseFloat(product.price?.retail_price || 0);
|
||||
|
||||
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
|
||||
this.items.push({
|
||||
@ -159,7 +161,7 @@ const useCart = defineStore('cart', {
|
||||
quantity: quantity,
|
||||
unit_price: parseFloat(product.price?.retail_price || 0),
|
||||
tax_rate: parseFloat(product.price?.tax || 16),
|
||||
max_stock: product.stock,
|
||||
max_stock: product.main_warehouse_stock ?? product.stock,
|
||||
track_serials: product.track_serials,
|
||||
serial_numbers: newSerials,
|
||||
// Campos para unidad de medida
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user