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 -->
|
<!-- 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>
|
||||||
|
|
||||||
|
|||||||
@ -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>
|
||||||
|
|
||||||
|
|||||||
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`), {
|
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: () => {
|
||||||
|
|||||||
@ -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';
|
||||||
|
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user