feat: agregar selección de seriales en movimientos y mejorar gestión de categorías y subcategorías

This commit is contained in:
Juan Felipe Zapata Moreno 2026-02-26 22:05:17 -06:00
parent 1909ebec68
commit fccb425781
4 changed files with 244 additions and 50 deletions

View File

@ -21,6 +21,10 @@ const props = defineProps({
warehouseId: {
type: Number,
default: null
},
preSelectedSerials: {
type: Array,
default: () => []
}
});
@ -59,10 +63,16 @@ const loadSerials = async () => {
loading.value = true;
try {
const response = await serialService.getAvailableSerials(props.product.id, props.warehouseId);
// Filtrar seriales que ya están en el carrito
availableSerials.value = (response.serials?.data || []).filter(
const fetched = (response.serials?.data || []).filter(
serial => !props.excludeSerials.includes(serial.serial_number)
);
const fetchedIds = new Set(fetched.map(s => s.id));
const extras = props.preSelectedSerials.filter(s => !fetchedIds.has(s.id));
availableSerials.value = [...extras, ...fetched];
selectedSerials.value = props.preSelectedSerials.filter(s =>
availableSerials.value.some(a => a.id === s.id)
);
} catch (error) {
console.error('Error loading serials:', error);
availableSerials.value = [];
@ -94,6 +104,7 @@ const clearSelection = () => {
const handleConfirm = () => {
emit('confirm', {
serials: selectedSerials.value,
serialNumbers: selectedSerials.value.map(s => s.serial_number),
quantity: selectedSerials.value.length
});

View File

@ -1,6 +1,6 @@
<script setup>
import { ref, watch, computed } from 'vue';
import { useForm, apiURL } from '@Services/Api';
import { useForm, useApi, apiURL } from '@Services/Api';
import Modal from '@Holos/Modal.vue';
import FormInput from '@Holos/Form/Input.vue';
@ -24,6 +24,8 @@ const units = ref([]);
const showCategoryCreate = ref(false);
const showSubcategoryCreate = ref(false);
const api = useApi();
/** Formulario */
const form = useForm({
name: '',
@ -68,9 +70,14 @@ const loadCategories = async () => {
};
const onCategoryChange = () => {
const selected = categories.value.find(c => c.id == form.category_id);
subcategories.value = selected?.subcategories ?? [];
form.subcategory_id = '';
subcategories.value = [];
if (!form.category_id) return;
api.get(apiURL(`categorias/${form.category_id}/subcategorias`), {
onSuccess: (data) => {
subcategories.value = data.subcategories?.data || data.subcategories || [];
}
});
};
const loadUnits = async () => {
@ -117,7 +124,9 @@ const validateSerialsAndUnit = () => {
const createProduct = () => {
form.transform((data) => ({
...data,
track_serials: selectedUnit.value ? !selectedUnit.value.allows_decimals && !!data.track_serials : false
track_serials: selectedUnit.value ? !selectedUnit.value.allows_decimals && !!data.track_serials : false,
category_id: data.category_id || null,
subcategory_id: data.category_id ? (data.subcategory_id || null) : null,
})).post(apiURL('inventario'), {
onSuccess: () => {
Notify.success('Producto creado exitosamente');
@ -253,7 +262,6 @@ watch(() => form.track_serials, () => {
<select
v-model="form.category_id"
class="flex-1 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"
required
@change="onCategoryChange"
>
<option value="">Seleccionar clasificación</option>
@ -286,9 +294,12 @@ watch(() => form.track_serials, () => {
<select
v-model="form.subcategory_id"
class="flex-1 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 disabled:opacity-50 disabled:cursor-not-allowed"
:disabled="!subcategories.length"
:disabled="!form.category_id || !subcategories.length"
:required="!!form.category_id"
>
<option value="">{{ subcategories.length ? 'Sin subclasificación' : 'Selecciona una clasificación primero' }}</option>
<option value="">
{{ !form.category_id ? 'Selecciona una clasificación primero' : subcategories.length ? 'Seleccionar subclasificación...' : 'Sin subclasificaciones disponibles' }}
</option>
<option
v-for="subcategory in subcategories"
:key="subcategory.id"

View File

@ -7,6 +7,8 @@ import Modal from '@Holos/Modal.vue';
import FormInput from '@Holos/Form/Input.vue';
import FormError from '@Holos/Form/Elements/Error.vue';
import GoogleIcon from '@Shared/GoogleIcon.vue';
import CategoryCreateModal from '@Pages/POS/Category/CreateModal.vue';
import SubcategoryCreateModal from '@Pages/POS/Category/Subcategories/CreateModal.vue';
const router = useRouter();
@ -24,6 +26,8 @@ const categories = ref([]);
const subcategories = ref([]);
const units = ref([]);
const activeTab = ref('general');
const showCategoryCreate = ref(false);
const showSubcategoryCreate = ref(false);
// Estado de equivalencias
const equivalences = ref([]);
@ -71,6 +75,16 @@ const canHaveEquivalences = computed(() => {
return props.product && !props.product.track_serials;
});
// La unidad se bloquea si el producto ya tiene movimientos de inventario
const unitLocked = computed(() => {
if (!props.product?.id) return false;
return (
Number(props.product?.movements_count || 0) > 0 ||
Number(props.product?.stock || 0) > 0 ||
Number(props.product?.serials_count || 0) > 0
);
});
// Unidades disponibles para agregar equivalencia (excluir la base y las ya usadas)
const availableUnitsForEquivalence = computed(() => {
const usedIds = equivalences.value.map(e => e.unit_of_measure_id);
@ -78,6 +92,9 @@ const availableUnitsForEquivalence = computed(() => {
return units.value.filter(u => {
if (u.id === form.unit_of_measure_id) return false; // excluir unidad base
if (usedIds.includes(u.id) && u.id !== editingId) return false; // excluir ya usadas (excepto la que se edita)
const name = (u.name || '').toLowerCase();
const abbr = (u.abbreviation || '').toLowerCase();
if (name.includes('serial') || abbr.includes('serial')) return false; // no aplica como equivalencia
return true;
});
});
@ -94,10 +111,13 @@ const loadCategories = async () => {
const result = await response.json();
if (result.data && result.data.categories && result.data.categories.data) {
categories.value = result.data.categories.data;
// Actualizar subcategorías si ya hay una categoría seleccionada
// Cargar subcategorías si ya hay una categoría seleccionada (producto existente)
if (form.category_id) {
const selected = categories.value.find(c => c.id == form.category_id);
subcategories.value = selected?.subcategories ?? [];
api.get(apiURL(`categorias/${form.category_id}/subcategorias`), {
onSuccess: (data) => {
subcategories.value = data.subcategories?.data || data.subcategories || [];
}
});
}
}
} catch (error) {
@ -106,9 +126,14 @@ const loadCategories = async () => {
};
const onCategoryChange = () => {
const selected = categories.value.find(c => c.id == form.category_id);
subcategories.value = selected?.subcategories ?? [];
form.subcategory_id = '';
subcategories.value = [];
if (!form.category_id) return;
api.get(apiURL(`categorias/${form.category_id}/subcategorias`), {
onSuccess: (data) => {
subcategories.value = data.subcategories?.data || data.subcategories || [];
}
});
};
const loadUnits = async () => {
@ -171,7 +196,9 @@ const updateProduct = () => {
...data,
track_serials: selectedUnit.value && !selectedUnit.value.allows_decimals
? (hasSerials || !!data.track_serials)
: false
: false,
category_id: data.category_id || null,
subcategory_id: data.category_id ? (data.subcategory_id || null) : null,
})).put(apiURL(`inventario/${props.product.id}`), {
onSuccess: () => {
Notify.success('Producto actualizado exitosamente');
@ -194,6 +221,21 @@ const closeModal = () => {
emit('close');
};
const onCategoryCreated = async (newCategory) => {
await loadCategories();
if (newCategory) {
form.category_id = newCategory.id;
onCategoryChange();
}
};
const onSubcategoryCreated = (newSubcategory) => {
if (newSubcategory) {
subcategories.value.push(newSubcategory);
form.subcategory_id = newSubcategory.id;
}
};
const openSerials = () => {
if (selectedUnit.value && selectedUnit.value.allows_decimals) {
Notify.warning('No se pueden usar números de serie con esta unidad de medida.');
@ -440,21 +482,30 @@ watch(activeTab, (tab) => {
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
CLASIFICACIÓN
</label>
<select
v-model="form.category_id"
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"
required
@change="onCategoryChange"
>
<option value="">Seleccionar clasificación</option>
<option
v-for="category in categories"
:key="category.id"
:value="category.id"
<div class="flex gap-2">
<select
v-model="form.category_id"
class="flex-1 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"
@change="onCategoryChange"
>
{{ category.name }}
</option>
</select>
<option value="">Seleccionar clasificación</option>
<option
v-for="category in categories"
:key="category.id"
:value="category.id"
>
{{ category.name }}
</option>
</select>
<button
type="button"
@click="showCategoryCreate = true"
class="flex items-center justify-center w-9 h-9 rounded-lg bg-indigo-50 hover:bg-indigo-100 text-indigo-600 border border-indigo-200 dark:bg-indigo-900/20 dark:hover:bg-indigo-900/40 dark:text-indigo-400 dark:border-indigo-800 transition-colors shrink-0"
title="Crear nueva clasificación"
>
<GoogleIcon name="add" class="text-xl" />
</button>
</div>
<FormError :message="form.errors?.category_id" />
</div>
@ -463,20 +514,34 @@ watch(activeTab, (tab) => {
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
SUBCLASIFICACIÓN
</label>
<select
v-model="form.subcategory_id"
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 disabled:opacity-50 disabled:cursor-not-allowed"
:disabled="!subcategories.length"
>
<option value="">{{ subcategories.length ? 'Sin subclasificación' : 'Selecciona una clasificación primero' }}</option>
<option
v-for="subcategory in subcategories"
:key="subcategory.id"
:value="subcategory.id"
<div class="flex gap-2">
<select
v-model="form.subcategory_id"
class="flex-1 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 disabled:opacity-50 disabled:cursor-not-allowed"
:disabled="!form.category_id || !subcategories.length"
:required="!!form.category_id"
>
{{ subcategory.name }}
</option>
</select>
<option value="">
{{ !form.category_id ? 'Selecciona una clasificación primero' : subcategories.length ? 'Seleccionar subclasificación...' : 'Sin subclasificaciones disponibles' }}
</option>
<option
v-for="subcategory in subcategories"
:key="subcategory.id"
:value="subcategory.id"
>
{{ subcategory.name }}
</option>
</select>
<button
type="button"
@click="showSubcategoryCreate = true"
:disabled="!form.category_id"
class="flex items-center justify-center w-9 h-9 rounded-lg bg-indigo-50 hover:bg-indigo-100 text-indigo-600 border border-indigo-200 dark:bg-indigo-900/20 dark:hover:bg-indigo-900/40 dark:text-indigo-400 dark:border-indigo-800 transition-colors shrink-0 disabled:opacity-40 disabled:cursor-not-allowed"
title="Crear nueva subclasificación"
>
<GoogleIcon name="add" class="text-xl" />
</button>
</div>
<FormError :message="form.errors?.subcategory_id" />
</div>
@ -487,7 +552,8 @@ watch(activeTab, (tab) => {
</label>
<select
v-model="form.unit_of_measure_id"
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"
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 disabled:opacity-50 disabled:cursor-not-allowed"
:disabled="unitLocked"
required
>
<option :value="null">Seleccionar unidad</option>
@ -500,7 +566,11 @@ watch(activeTab, (tab) => {
</option>
</select>
<FormError :message="form.errors?.unit_of_measure_id" />
<p v-if="selectedUnit" class="mt-1 text-xs text-gray-500 dark:text-gray-400">
<p v-if="unitLocked" class="mt-1 text-xs text-amber-600 dark:text-amber-400 flex items-center gap-1">
<GoogleIcon name="lock" class="text-xs" />
No se puede cambiar: el producto ya tiene movimientos de inventario
</p>
<p v-else-if="selectedUnit" class="mt-1 text-xs text-gray-500 dark:text-gray-400">
<span v-if="selectedUnit.allows_decimals">Esta unidad permite cantidades decimales (ej: 25.750)</span>
<span v-else>Esta unidad solo permite cantidades enteras</span>
</p>
@ -775,4 +845,17 @@ watch(activeTab, (tab) => {
</div>
</div>
</Modal>
<CategoryCreateModal
:show="showCategoryCreate"
@close="showCategoryCreate = false"
@created="onCategoryCreated"
/>
<SubcategoryCreateModal
:show="showSubcategoryCreate"
:category-id="form.category_id"
@close="showSubcategoryCreate = false"
@created="onSubcategoryCreated"
/>
</template>

View File

@ -8,6 +8,7 @@ import FormInput from '@Holos/Form/Input.vue';
import FormError from '@Holos/Form/Elements/Error.vue';
import GoogleIcon from '@Shared/GoogleIcon.vue';
import SerialInputList from '@Components/POS/SerialInputList.vue';
import SerialSelector from '@Components/POS/SerialSelector.vue';
/** Eventos */
const emit = defineEmits(['close', 'updated']);
@ -23,7 +24,8 @@ const warehouses = ref([]);
const suppliers = ref([]);
const loading = ref(false);
const serialsText = ref('');
const serialNumbers = ref([]);
const showSerialSelector = ref(false);
const selectedSerialObjects = ref([]);
const api = useApi();
/** Formulario */
@ -126,6 +128,21 @@ const updateQuantityFromSerials = () => {
form.quantity = count > 0 ? count : 1;
};
// Seriales pre-seleccionados para SerialSelector (objetos completos con id)
const serialSelectorPreSelected = computed(() => selectedSerialObjects.value);
/** Métodos de selección de seriales (para traspasos y salidas) */
const openSerialSelector = () => {
showSerialSelector.value = true;
};
const handleSerialsConfirmed = ({ serials, serialNumbers }) => {
selectedSerialObjects.value = serials;
serialsList.value = serialNumbers.map(sn => ({ serial_number: sn, locked: false }));
updateQuantityFromSerials();
showSerialSelector.value = false;
};
/** Métodos */
const loadWarehouses = () => {
loading.value = true;
@ -190,7 +207,7 @@ const updateMovement = () => {
api.put(apiURL(`movimientos/${props.movement.id}`), {
data,
onSuccess: (response) => {
onSuccess: () => {
window.Notify.success('Movimiento actualizado correctamente');
emit('updated');
closeModal();
@ -206,6 +223,7 @@ const updateMovement = () => {
const closeModal = () => {
form.reset();
showSerialSelector.value = false;
emit('close');
};
@ -231,6 +249,11 @@ watch(() => props.show, (isShown) => {
? (props.movement.exited_serials || [])
: (props.movement.serials || []);
// Para traspasos/salidas: inicializar objetos de seriales para SerialSelector
if (isTransfer || isExit) {
selectedSerialObjects.value = rawSerials;
}
if (rawSerials.length > 0) {
const movementWarehouseId = props.movement.warehouse_id || props.movement.warehouse_to?.id;
@ -357,8 +380,65 @@ watch(() => props.show, (isShown) => {
<FormError :message="form.errors?.quantity" />
</div>
<!-- Números de Serie (solo si el producto los tiene) -->
<div v-if="hasSerials" class="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg p-4">
<!-- Números de Serie: traspasos y salidas usan selector -->
<div
v-if="hasSerials && (movement?.movement_type === 'transfer' || movement?.movement_type === 'exit')"
class="p-3 bg-blue-50 dark:bg-blue-900/10 border border-blue-200 dark:border-blue-800 rounded-lg"
>
<div class="flex items-center justify-between gap-2">
<div class="flex items-center gap-2 flex-1">
<GoogleIcon name="qr_code_2" class="text-blue-600 dark:text-blue-400 text-lg" />
<div>
<p class="text-xs font-semibold text-blue-900 dark:text-blue-100">
Este producto requiere números de serie
</p>
<p class="text-xs text-blue-700 dark:text-blue-300">
{{ serialsArray.length }} serial(es) seleccionado(s)
</p>
</div>
</div>
<button
type="button"
@click="openSerialSelector"
class="flex items-center gap-1 px-3 py-1.5 text-xs font-semibold text-white bg-blue-600 hover:bg-blue-700 rounded transition-colors"
>
<GoogleIcon name="qr_code_scanner" class="text-sm" />
{{ serialsArray.length > 0 ? 'Cambiar' : 'Seleccionar' }}
</button>
</div>
<!-- Badges de seriales seleccionados -->
<div v-if="serialsArray.length > 0" class="mt-2 pt-2 border-t border-blue-200 dark:border-blue-800">
<p class="text-xs font-medium text-blue-900 dark:text-blue-100 mb-1">Seriales seleccionados:</p>
<div class="flex flex-wrap gap-1">
<span
v-for="(serial, idx) in serialsArray.slice(0, 5)"
:key="idx"
class="inline-flex items-center px-2 py-0.5 text-xs font-mono bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-200 rounded"
>
{{ serial }}
</span>
<span
v-if="serialsArray.length > 5"
class="inline-flex items-center px-2 py-0.5 text-xs bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 rounded"
>
+{{ serialsArray.length - 5 }} más
</span>
</div>
</div>
<!-- Validación -->
<div v-if="serialsArray.length > 0" class="mt-2 flex items-center justify-end">
<p v-if="!serialsValidation.valid" class="text-xs text-red-600 dark:text-red-400 font-medium">
{{ serialsValidation.message }}
</p>
<p v-else class="text-xs text-green-600 dark:text-green-400 font-medium flex items-center gap-1">
<GoogleIcon name="check_circle" class="text-sm" />
Válido
</p>
</div>
</div>
<!-- Números de Serie: entradas usan lista de inputs -->
<div v-else-if="hasSerials" class="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg p-4">
<div class="flex items-center gap-2 mb-2">
<GoogleIcon name="qr_code_scanner" class="text-amber-600 dark:text-amber-400" />
<label class="text-xs font-semibold text-amber-700 dark:text-amber-300 uppercase">
@ -367,10 +447,8 @@ watch(() => props.show, (isShown) => {
</div>
<SerialInputList
v-model="serialsList"
:allow-remove="movement?.movement_type !== 'transfer'"
@update:model-value="updateQuantityFromSerials"
/>
<!-- Validación -->
<div class="mt-2 flex items-center justify-between">
<p class="text-xs text-amber-600 dark:text-amber-400">
@ -567,5 +645,16 @@ watch(() => props.show, (isShown) => {
</div>
</form>
</div>
<!-- Selector de seriales (para traspasos y salidas) -->
<SerialSelector
v-if="movement?.inventory && showSerialSelector"
:show="showSerialSelector"
:product="{ id: movement.inventory?.id || movement.inventory_id, name: movement.inventory?.name, sku: movement.inventory?.sku, track_serials: movement.inventory?.track_serials }"
:warehouse-id="Number(form.origin_warehouse_id) || null"
:pre-selected-serials="serialSelectorPreSelected"
@close="showSerialSelector = false"
@confirm="handleSerialsConfirmed"
/>
</Modal>
</template>