862 lines
39 KiB
Vue
862 lines
39 KiB
Vue
<script setup>
|
|
import { ref, watch, computed } from 'vue';
|
|
import { useRouter } from 'vue-router';
|
|
import { useForm, useApi, 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';
|
|
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();
|
|
|
|
/** Eventos */
|
|
const emit = defineEmits(['close', 'updated']);
|
|
|
|
/** Propiedades */
|
|
const props = defineProps({
|
|
show: Boolean,
|
|
product: Object
|
|
});
|
|
|
|
/** Estado */
|
|
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([]);
|
|
const baseUnit = ref(null);
|
|
const loadingEquivalences = ref(false);
|
|
const showEquivalenceForm = ref(false);
|
|
const editingEquivalence = ref(null);
|
|
|
|
const api = useApi();
|
|
|
|
/** Formulario principal */
|
|
const form = useForm({
|
|
name: '',
|
|
key_sat: '',
|
|
sku: '',
|
|
barcode: '',
|
|
category_id: '',
|
|
subcategory_id: '',
|
|
unit_of_measure_id: null,
|
|
retail_price: 0,
|
|
tax: 16,
|
|
track_serials: false
|
|
});
|
|
|
|
/** Formulario de equivalencia */
|
|
const eqForm = useForm({
|
|
unit_of_measure_id: null,
|
|
conversion_factor: '',
|
|
retail_price: ''
|
|
});
|
|
|
|
/** Computed */
|
|
const selectedUnit = computed(() => {
|
|
if (!form.unit_of_measure_id) return null;
|
|
return units.value.find(u => u.id === form.unit_of_measure_id);
|
|
});
|
|
|
|
const canUseSerials = computed(() => {
|
|
if (!selectedUnit.value) return true;
|
|
if (selectedUnit.value.allows_decimals) return false;
|
|
return equivalences.value.length === 0; // No puede tener seriales si ya tiene equivalencias
|
|
});
|
|
|
|
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);
|
|
const editingId = editingEquivalence.value?.unit_of_measure_id;
|
|
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;
|
|
});
|
|
});
|
|
|
|
/** Métodos */
|
|
const loadCategories = async () => {
|
|
try {
|
|
const response = await fetch(apiURL('categorias'), {
|
|
headers: {
|
|
'Authorization': `Bearer ${sessionStorage.token}`,
|
|
'Accept': 'application/json'
|
|
}
|
|
});
|
|
const result = await response.json();
|
|
if (result.data && result.data.categories && result.data.categories.data) {
|
|
categories.value = result.data.categories.data;
|
|
// Cargar subcategorías si ya hay una categoría seleccionada (producto existente)
|
|
if (form.category_id) {
|
|
api.get(apiURL(`categorias/${form.category_id}/subcategorias`), {
|
|
onSuccess: (data) => {
|
|
subcategories.value = data.subcategories?.data || data.subcategories || [];
|
|
}
|
|
});
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error('Error loading categories:', error);
|
|
}
|
|
};
|
|
|
|
const onCategoryChange = () => {
|
|
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 () => {
|
|
try {
|
|
const response = await fetch(apiURL('unidades-medida/active'), {
|
|
headers: {
|
|
'Authorization': `Bearer ${sessionStorage.token}`,
|
|
'Accept': 'application/json'
|
|
}
|
|
});
|
|
const result = await response.json();
|
|
if (result.data && result.data.units) {
|
|
units.value = result.data.units;
|
|
}
|
|
} catch (error) {
|
|
console.error('Error loading units:', error);
|
|
}
|
|
};
|
|
|
|
const loadEquivalences = () => {
|
|
if (!props.product?.id) return;
|
|
loadingEquivalences.value = true;
|
|
|
|
api.get(apiURL(`inventario/${props.product.id}/equivalencias`), {
|
|
onSuccess: (data) => {
|
|
equivalences.value = data.equivalences || [];
|
|
baseUnit.value = data.base_unit || null;
|
|
},
|
|
onFail: () => {
|
|
equivalences.value = [];
|
|
},
|
|
onFinish: () => {
|
|
loadingEquivalences.value = false;
|
|
}
|
|
});
|
|
};
|
|
|
|
const validateSerialsAndUnit = () => {
|
|
if (!selectedUnit.value) return;
|
|
|
|
if (selectedUnit.value.allows_decimals) {
|
|
if (form.track_serials) {
|
|
Notify.warning('No se pueden usar números de serie con esta unidad de medida.');
|
|
}
|
|
form.track_serials = false;
|
|
return;
|
|
}
|
|
|
|
const unitName = (selectedUnit.value.name || '').toLowerCase();
|
|
const unitAbbr = (selectedUnit.value.abbreviation || '').toLowerCase();
|
|
if (unitName.includes('serial') || unitAbbr.includes('serial')) {
|
|
form.track_serials = true;
|
|
}
|
|
};
|
|
|
|
const updateProduct = () => {
|
|
const hasSerials = Number(props.product?.serials_count || 0) > 0;
|
|
|
|
form.transform((data) => ({
|
|
...data,
|
|
track_serials: selectedUnit.value && !selectedUnit.value.allows_decimals
|
|
? (hasSerials || !!data.track_serials)
|
|
: 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');
|
|
emit('updated');
|
|
closeModal();
|
|
},
|
|
onError: () => {
|
|
Notify.error('Error al actualizar el producto');
|
|
}
|
|
});
|
|
};
|
|
|
|
const closeModal = () => {
|
|
form.reset();
|
|
activeTab.value = 'general';
|
|
showEquivalenceForm.value = false;
|
|
editingEquivalence.value = null;
|
|
equivalences.value = [];
|
|
baseUnit.value = null;
|
|
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.');
|
|
return;
|
|
}
|
|
form.track_serials = true;
|
|
closeModal();
|
|
router.push({ name: 'pos.inventory.serials', params: { id: props.product.id } });
|
|
};
|
|
|
|
// Equivalencias
|
|
const openAddEquivalence = () => {
|
|
editingEquivalence.value = null;
|
|
eqForm.unit_of_measure_id = null;
|
|
eqForm.conversion_factor = '';
|
|
eqForm.retail_price = '';
|
|
showEquivalenceForm.value = true;
|
|
};
|
|
|
|
const openEditEquivalence = (eq) => {
|
|
editingEquivalence.value = eq;
|
|
eqForm.unit_of_measure_id = eq.unit_of_measure_id;
|
|
eqForm.conversion_factor = parseFloat(eq.conversion_factor);
|
|
eqForm.retail_price = eq.retail_price ? parseFloat(eq.retail_price) : '';
|
|
showEquivalenceForm.value = true;
|
|
};
|
|
|
|
const cancelEquivalenceForm = () => {
|
|
showEquivalenceForm.value = false;
|
|
editingEquivalence.value = null;
|
|
eqForm.reset();
|
|
};
|
|
|
|
const saveEquivalence = () => {
|
|
const productId = props.product.id;
|
|
|
|
if (editingEquivalence.value) {
|
|
const eqId = editingEquivalence.value.id;
|
|
eqForm.transform((data) => ({
|
|
conversion_factor: data.conversion_factor,
|
|
retail_price: data.retail_price || undefined,
|
|
})).put(apiURL(`inventario/${productId}/equivalencias/${eqId}`), {
|
|
onSuccess: () => {
|
|
Notify.success('Equivalencia actualizada');
|
|
cancelEquivalenceForm();
|
|
loadEquivalences();
|
|
},
|
|
onError: () => {
|
|
Notify.error('Error al actualizar la equivalencia');
|
|
}
|
|
});
|
|
} else {
|
|
eqForm.transform((data) => ({
|
|
unit_of_measure_id: data.unit_of_measure_id,
|
|
conversion_factor: data.conversion_factor,
|
|
retail_price: data.retail_price || undefined,
|
|
})).post(apiURL(`inventario/${productId}/equivalencias`), {
|
|
onSuccess: () => {
|
|
Notify.success('Equivalencia creada');
|
|
cancelEquivalenceForm();
|
|
loadEquivalences();
|
|
},
|
|
onError: () => {
|
|
Notify.error('Error al crear la equivalencia');
|
|
}
|
|
});
|
|
}
|
|
};
|
|
|
|
const deleteEquivalence = (eq) => {
|
|
if (!confirm(`¿Eliminar la equivalencia con "${eq.unit_name}"?`)) return;
|
|
|
|
api.delete(apiURL(`inventario/${props.product.id}/equivalencias/${eq.id}`), {
|
|
onSuccess: () => {
|
|
Notify.success('Equivalencia eliminada');
|
|
loadEquivalences();
|
|
},
|
|
onFail: (data) => {
|
|
Notify.error(data.message || 'Error al eliminar la equivalencia');
|
|
},
|
|
onError: () => {
|
|
Notify.error('Error de conexión');
|
|
}
|
|
});
|
|
};
|
|
|
|
/** Observadores */
|
|
watch(() => props.product, (newProduct) => {
|
|
if (newProduct) {
|
|
form.name = newProduct.name || '';
|
|
form.key_sat = newProduct.key_sat || '';
|
|
form.sku = newProduct.sku || '';
|
|
form.barcode = newProduct.barcode || '';
|
|
form.category_id = newProduct.category_id || '';
|
|
form.subcategory_id = newProduct.subcategory_id || '';
|
|
form.unit_of_measure_id = newProduct.unit_of_measure_id || null;
|
|
form.cost = parseFloat(newProduct.price?.cost || 0);
|
|
form.retail_price = parseFloat(newProduct.price?.retail_price || 0);
|
|
form.tax = parseFloat(newProduct.price?.tax || 16);
|
|
|
|
const serialCount = Number(newProduct.serials_count || 0);
|
|
form.track_serials = !!newProduct.track_serials || serialCount > 0;
|
|
}
|
|
}, { immediate: true });
|
|
|
|
watch(() => props.show, async (newValue) => {
|
|
if (newValue) {
|
|
loadUnits();
|
|
activeTab.value = 'general';
|
|
await loadCategories();
|
|
}
|
|
});
|
|
|
|
watch(selectedUnit, () => {
|
|
validateSerialsAndUnit();
|
|
});
|
|
|
|
watch(activeTab, (tab) => {
|
|
if (tab === 'equivalences' && props.product?.id) {
|
|
loadEquivalences();
|
|
}
|
|
});
|
|
</script>
|
|
|
|
<template>
|
|
<Modal :show="show" max-width="md" @close="closeModal">
|
|
<div class="p-6">
|
|
<!-- Header -->
|
|
<div class="flex items-center justify-between mb-4">
|
|
<h3 class="text-lg font-bold text-gray-900 dark:text-gray-100">
|
|
Editar Producto
|
|
</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>
|
|
|
|
<!-- Tabs -->
|
|
<div class="flex border-b border-gray-200 dark:border-gray-700 mb-5">
|
|
<button
|
|
@click="activeTab = 'general'"
|
|
:class="[
|
|
'px-4 py-2 text-sm font-medium border-b-2 transition-colors',
|
|
activeTab === 'general'
|
|
? 'border-indigo-500 text-indigo-600 dark:text-indigo-400'
|
|
: 'border-transparent text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'
|
|
]"
|
|
>
|
|
Información General
|
|
</button>
|
|
<button
|
|
v-if="canHaveEquivalences"
|
|
@click="activeTab = 'equivalences'"
|
|
:class="[
|
|
'px-4 py-2 text-sm font-medium border-b-2 transition-colors flex items-center gap-1.5',
|
|
activeTab === 'equivalences'
|
|
? 'border-indigo-500 text-indigo-600 dark:text-indigo-400'
|
|
: 'border-transparent text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'
|
|
]"
|
|
>
|
|
Equivalencias
|
|
<span
|
|
v-if="equivalences.length > 0"
|
|
class="inline-flex items-center justify-center w-4 h-4 text-xs font-bold rounded-full bg-indigo-100 text-indigo-700 dark:bg-indigo-900 dark:text-indigo-300"
|
|
>
|
|
{{ equivalences.length }}
|
|
</span>
|
|
</button>
|
|
<div
|
|
v-else
|
|
class="px-4 py-2 text-sm font-medium border-b-2 border-transparent text-gray-300 dark:text-gray-600 cursor-not-allowed"
|
|
title="No disponible para productos con rastreo de seriales"
|
|
>
|
|
Equivalencias
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Tab: Información General -->
|
|
<div v-if="activeTab === 'general'">
|
|
<form @submit.prevent="updateProduct" class="space-y-4">
|
|
<div class="grid grid-cols-2 gap-4">
|
|
<!-- Nombre -->
|
|
<div class="col-span-2">
|
|
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
|
|
NOMBRE
|
|
</label>
|
|
<FormInput
|
|
v-model="form.name"
|
|
type="text"
|
|
placeholder="Nombre del producto"
|
|
required
|
|
/>
|
|
<FormError :message="form.errors?.name" />
|
|
</div>
|
|
|
|
<!-- Clave SAT -->
|
|
<div class="col-span-2">
|
|
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
|
|
CLAVE SAT
|
|
</label>
|
|
<FormInput
|
|
v-model="form.key_sat"
|
|
type="string"
|
|
placeholder="Clave SAT del producto"
|
|
/>
|
|
<FormError :message="form.errors?.key_sat" />
|
|
</div>
|
|
|
|
<!-- SKU -->
|
|
<div>
|
|
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
|
|
SKU
|
|
</label>
|
|
<FormInput
|
|
v-model="form.sku"
|
|
type="text"
|
|
placeholder="SKU"
|
|
required
|
|
/>
|
|
<FormError :message="form.errors?.sku" />
|
|
</div>
|
|
|
|
<!-- Código de Barras -->
|
|
<div>
|
|
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
|
|
CÓDIGO DE BARRAS
|
|
</label>
|
|
<FormInput
|
|
v-model="form.barcode"
|
|
type="text"
|
|
placeholder="1234567890123"
|
|
maxlength="100"
|
|
/>
|
|
<FormError :message="form.errors?.barcode" />
|
|
</div>
|
|
|
|
<!-- Categoría -->
|
|
<div class="col-span-2">
|
|
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
|
|
CLASIFICACIÓN
|
|
</label>
|
|
<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"
|
|
>
|
|
<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>
|
|
|
|
<!-- Subclasificación -->
|
|
<div class="col-span-2">
|
|
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
|
|
SUBCLASIFICACIÓN
|
|
</label>
|
|
<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"
|
|
>
|
|
<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>
|
|
|
|
<!-- Unidad de Medida -->
|
|
<div class="col-span-2">
|
|
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
|
|
UNIDAD DE MEDIDA *
|
|
</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 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
:disabled="unitLocked"
|
|
required
|
|
>
|
|
<option :value="null">Seleccionar unidad</option>
|
|
<option
|
|
v-for="unit in units"
|
|
:key="unit.id"
|
|
:value="unit.id"
|
|
>
|
|
{{ unit.name }} ({{ unit.abbreviation }})
|
|
</option>
|
|
</select>
|
|
<FormError :message="form.errors?.unit_of_measure_id" />
|
|
<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>
|
|
</div>
|
|
|
|
<!-- Precio de Venta -->
|
|
<div>
|
|
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
|
|
PRECIO VENTA
|
|
</label>
|
|
<FormInput
|
|
v-model.number="form.retail_price"
|
|
type="number"
|
|
min="0"
|
|
step="0.01"
|
|
placeholder="0.00"
|
|
required
|
|
/>
|
|
<FormError :message="form.errors?.retail_price" />
|
|
</div>
|
|
|
|
<!-- Impuesto/Tax -->
|
|
<div>
|
|
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
|
|
IMPUESTO (%)
|
|
</label>
|
|
<FormInput
|
|
v-model.number="form.tax"
|
|
type="number"
|
|
min="0"
|
|
max="100"
|
|
step="0.01"
|
|
placeholder="16.00"
|
|
required
|
|
/>
|
|
<FormError :message="form.errors?.tax" />
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Botones -->
|
|
<div class="flex items-center justify-between mt-6">
|
|
<button
|
|
v-if="canUseSerials"
|
|
type="button"
|
|
@click="openSerials"
|
|
class="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-emerald-700 bg-emerald-50 border border-emerald-200 rounded-lg hover:bg-emerald-100 transition-colors"
|
|
>
|
|
<GoogleIcon name="qr_code_2" class="text-lg" />
|
|
Gestionar Seriales
|
|
</button>
|
|
<div
|
|
v-else
|
|
class="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-gray-400 bg-gray-50 border border-gray-200 rounded-lg cursor-not-allowed"
|
|
:title="equivalences.length > 0 ? 'No disponible: el producto tiene equivalencias de unidad' : selectedUnit?.allows_decimals ? `No disponible: ${selectedUnit.name} permite decimales` : 'Selecciona una unidad de medida'"
|
|
>
|
|
<GoogleIcon name="qr_code_2" class="text-lg opacity-50" />
|
|
Gestionar Seriales
|
|
</div>
|
|
<div class="flex items-center gap-3">
|
|
<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">Actualizando...</span>
|
|
<span v-else>Actualizar</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
|
|
<!-- Tab: Equivalencias -->
|
|
<div v-else-if="activeTab === 'equivalences'">
|
|
<!-- Unidad base -->
|
|
<div v-if="baseUnit" class="mb-4 p-3 bg-gray-50 dark:bg-gray-800 rounded-lg flex items-center gap-2">
|
|
<GoogleIcon name="info" class="text-gray-400 text-sm" />
|
|
<p class="text-sm text-gray-600 dark:text-gray-400">
|
|
Unidad base:
|
|
<span class="font-semibold text-gray-900 dark:text-gray-100">
|
|
{{ baseUnit.name }} ({{ baseUnit.abbreviation }})
|
|
</span>
|
|
</p>
|
|
</div>
|
|
|
|
<!-- Loading -->
|
|
<div v-if="loadingEquivalences" class="flex justify-center py-8">
|
|
<div class="animate-spin rounded-full h-6 w-6 border-b-2 border-indigo-600"></div>
|
|
</div>
|
|
|
|
<div v-else>
|
|
<!-- Lista de equivalencias -->
|
|
<div v-if="equivalences.length > 0" class="space-y-2 mb-4">
|
|
<div
|
|
v-for="eq in equivalences"
|
|
:key="eq.id"
|
|
class="flex items-center justify-between p-3 border border-gray-200 dark:border-gray-700 rounded-lg"
|
|
:class="{ 'opacity-50': !eq.is_active }"
|
|
>
|
|
<div class="flex-1 min-w-0">
|
|
<div class="flex items-center gap-2">
|
|
<p class="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
|
{{ eq.unit_name }}
|
|
<span class="font-mono text-gray-500 dark:text-gray-400 text-xs">({{ eq.unit_abbreviation }})</span>
|
|
</p>
|
|
<span
|
|
v-if="!eq.is_active"
|
|
class="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-gray-100 text-gray-500 dark:bg-gray-700 dark:text-gray-400"
|
|
>
|
|
Inactivo
|
|
</span>
|
|
</div>
|
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
|
|
1 {{ eq.unit_name }} = {{ parseFloat(eq.conversion_factor) }} {{ baseUnit?.abbreviation }}
|
|
·
|
|
Precio: ${{ parseFloat(eq.retail_price).toFixed(2) }}
|
|
</p>
|
|
</div>
|
|
<div class="flex items-center gap-1 ml-2">
|
|
<button
|
|
@click="openEditEquivalence(eq)"
|
|
class="p-1.5 text-indigo-600 hover:bg-indigo-50 dark:text-indigo-400 dark:hover:bg-indigo-900 rounded-lg transition-colors"
|
|
title="Editar equivalencia"
|
|
>
|
|
<GoogleIcon name="edit" class="text-base" />
|
|
</button>
|
|
<button
|
|
@click="deleteEquivalence(eq)"
|
|
class="p-1.5 text-red-600 hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-900 rounded-lg transition-colors"
|
|
title="Eliminar equivalencia"
|
|
>
|
|
<GoogleIcon name="delete" class="text-base" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div
|
|
v-else-if="!showEquivalenceForm"
|
|
class="flex flex-col items-center justify-center py-6 text-gray-400"
|
|
>
|
|
<GoogleIcon name="straighten" class="text-4xl mb-2 opacity-50" />
|
|
<p class="text-sm">Este producto no tiene equivalencias</p>
|
|
</div>
|
|
|
|
<!-- Formulario inline de equivalencia -->
|
|
<div v-if="showEquivalenceForm" class="border border-indigo-200 dark:border-indigo-800 rounded-lg p-4 bg-indigo-50 dark:bg-indigo-950 space-y-3">
|
|
<h4 class="text-sm font-semibold text-indigo-800 dark:text-indigo-200">
|
|
{{ editingEquivalence ? 'Editar equivalencia' : 'Nueva equivalencia' }}
|
|
</h4>
|
|
|
|
<!-- Unidad (solo en creación) -->
|
|
<div v-if="!editingEquivalence">
|
|
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
|
|
UNIDAD
|
|
</label>
|
|
<select
|
|
v-model="eqForm.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"
|
|
required
|
|
>
|
|
<option :value="null">Seleccionar unidad</option>
|
|
<option
|
|
v-for="unit in availableUnitsForEquivalence"
|
|
:key="unit.id"
|
|
:value="unit.id"
|
|
>
|
|
{{ unit.name }} ({{ unit.abbreviation }})
|
|
</option>
|
|
</select>
|
|
<FormError :message="eqForm.errors?.unit_of_measure_id" />
|
|
</div>
|
|
|
|
<!-- Si estamos editando, mostrar la unidad como texto -->
|
|
<div v-else>
|
|
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
|
|
UNIDAD
|
|
</label>
|
|
<p class="text-sm font-semibold text-gray-700 dark:text-gray-300">
|
|
{{ editingEquivalence.unit_name }} ({{ editingEquivalence.unit_abbreviation }})
|
|
</p>
|
|
</div>
|
|
|
|
<div class="grid grid-cols-2 gap-3">
|
|
<!-- Factor de conversión -->
|
|
<div>
|
|
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
|
|
FACTOR DE CONVERSIÓN
|
|
</label>
|
|
<FormInput
|
|
v-model.number="eqForm.conversion_factor"
|
|
type="number"
|
|
min="0"
|
|
step="any"
|
|
placeholder="Ej: 24"
|
|
required
|
|
/>
|
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
|
1 unidad = X {{ baseUnit?.abbreviation || 'base' }}
|
|
</p>
|
|
<FormError :message="eqForm.errors?.conversion_factor" />
|
|
</div>
|
|
|
|
<!-- Precio sugerido -->
|
|
<div>
|
|
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
|
|
PRECIO SUGERIDO
|
|
</label>
|
|
<FormInput
|
|
v-model.number="eqForm.retail_price"
|
|
type="number"
|
|
min="0"
|
|
step="0.01"
|
|
placeholder="Automático"
|
|
/>
|
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
|
Dejar vacío para calcular automáticamente
|
|
</p>
|
|
<FormError :message="eqForm.errors?.retail_price" />
|
|
</div>
|
|
</div>
|
|
|
|
<div class="flex items-center justify-end gap-2 pt-1">
|
|
<button
|
|
type="button"
|
|
@click="cancelEquivalenceForm"
|
|
class="px-3 py-1.5 text-xs font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
|
|
>
|
|
Cancelar
|
|
</button>
|
|
<button
|
|
type="button"
|
|
@click="saveEquivalence"
|
|
:disabled="eqForm.processing"
|
|
class="px-3 py-1.5 text-xs font-semibold text-white bg-indigo-600 rounded-lg hover:bg-indigo-700 disabled:opacity-50 transition-colors"
|
|
>
|
|
<span v-if="eqForm.processing">Guardando...</span>
|
|
<span v-else>{{ editingEquivalence ? 'Actualizar' : 'Agregar' }}</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Botón agregar equivalencia -->
|
|
<button
|
|
v-if="!showEquivalenceForm"
|
|
type="button"
|
|
@click="openAddEquivalence"
|
|
class="mt-3 w-full flex items-center justify-center gap-2 px-4 py-2 text-sm font-medium text-indigo-700 bg-indigo-50 border border-dashed border-indigo-300 rounded-lg hover:bg-indigo-100 dark:bg-indigo-900 dark:text-indigo-300 dark:border-indigo-700 dark:hover:bg-indigo-800 transition-colors"
|
|
>
|
|
<GoogleIcon name="add" class="text-lg" />
|
|
Agregar equivalencia
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Botón cerrar en tab equivalencias -->
|
|
<div class="flex justify-end 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 transition-colors"
|
|
>
|
|
Cerrar
|
|
</button>
|
|
</div>
|
|
</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>
|