feat: agregado unidad de medida crud
This commit is contained in:
parent
e653add755
commit
cf80e914fd
@ -129,8 +129,18 @@ const remove = () => {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Unidad de medida seleccionada (equivalencia) -->
|
||||||
|
<div v-if="item.unit_name" class="mb-1">
|
||||||
|
<span class="inline-flex items-center gap-1 text-xs font-medium text-amber-700 bg-amber-50 dark:bg-amber-900/20 dark:text-amber-400 px-1.5 py-0.5 rounded">
|
||||||
|
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 6l3 1m0 0l-3 9a5.002 5.002 0 006.001 0M6 7l3 9M6 7l6-2m6 2l3-1m-3 1l-3 9a5.002 5.002 0 006.001 0M18 7l3 9m-3-9l-6-2m0-2v2m0 16V5m0 16H9m3 0h3"/>
|
||||||
|
</svg>
|
||||||
|
{{ item.unit_name }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Mensaje para productos con decimales -->
|
<!-- Mensaje para productos con decimales -->
|
||||||
<div v-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 }}) - Permite decimales
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
38
src/components/POS/CfdiSelector.vue
Normal file
38
src/components/POS/CfdiSelector.vue
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
<script setup>
|
||||||
|
import { usoCfdiOptions } from '@/utils/fiscalData';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
modelValue: { type: String, default: '' },
|
||||||
|
error: { type: String, default: null },
|
||||||
|
required: { type: Boolean, default: false },
|
||||||
|
disabled: { type: Boolean, default: false }
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['update:modelValue']);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
|
||||||
|
USO DE CFDI <span v-if="required" class="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
:value="modelValue"
|
||||||
|
:disabled="disabled"
|
||||||
|
:required="required"
|
||||||
|
@change="emit('update:modelValue', $event.target.value)"
|
||||||
|
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"
|
||||||
|
:class="{ 'border-red-500': error }"
|
||||||
|
>
|
||||||
|
<option value="" disabled>Seleccionar uso de CFDI</option>
|
||||||
|
<option
|
||||||
|
v-for="option in usoCfdiOptions"
|
||||||
|
:key="option.value"
|
||||||
|
:value="option.value"
|
||||||
|
>
|
||||||
|
{{ option.label }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<p v-if="error" class="mt-1 text-xs text-red-600 dark:text-red-400">{{ error }}</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
38
src/components/POS/RegimenSelecto.vue
Normal file
38
src/components/POS/RegimenSelecto.vue
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
<script setup>
|
||||||
|
import { regimenFiscalOptions } from '@/utils/fiscalData';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
modelValue: { type: String, default: '' },
|
||||||
|
error: { type: String, default: null },
|
||||||
|
required: { type: Boolean, default: false },
|
||||||
|
disabled: { type: Boolean, default: false }
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['update:modelValue']);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
|
||||||
|
RÉGIMEN FISCAL <span v-if="required" class="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
:value="modelValue"
|
||||||
|
:disabled="disabled"
|
||||||
|
:required="required"
|
||||||
|
@change="emit('update:modelValue', $event.target.value)"
|
||||||
|
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"
|
||||||
|
:class="{ 'border-red-500': error }"
|
||||||
|
>
|
||||||
|
<option value="" disabled>Seleccionar régimen fiscal</option>
|
||||||
|
<option
|
||||||
|
v-for="option in regimenFiscalOptions"
|
||||||
|
:key="option.value"
|
||||||
|
:value="option.value"
|
||||||
|
>
|
||||||
|
{{ option.label }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<p v-if="error" class="mt-1 text-xs text-red-600 dark:text-red-400">{{ error }}</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
167
src/components/POS/UnitEquivalenceSelector.vue
Normal file
167
src/components/POS/UnitEquivalenceSelector.vue
Normal file
@ -0,0 +1,167 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, computed } from 'vue';
|
||||||
|
import Modal from '@Holos/Modal.vue';
|
||||||
|
import GoogleIcon from '@Shared/GoogleIcon.vue';
|
||||||
|
|
||||||
|
/** Propiedades */
|
||||||
|
const props = defineProps({
|
||||||
|
show: Boolean,
|
||||||
|
product: Object,
|
||||||
|
equivalences: {
|
||||||
|
type: Array,
|
||||||
|
default: () => []
|
||||||
|
},
|
||||||
|
baseUnit: Object
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Eventos */
|
||||||
|
const emit = defineEmits(['confirm', 'close']);
|
||||||
|
|
||||||
|
/** Estado */
|
||||||
|
const selectedOption = ref(null);
|
||||||
|
|
||||||
|
/** Todas las opciones: unidad base + equivalencias activas */
|
||||||
|
const options = computed(() => {
|
||||||
|
const basePrice = parseFloat(props.product?.price?.retail_price || 0);
|
||||||
|
const baseName = props.baseUnit?.name || props.product?.unit_of_measure?.name || 'Unidad base';
|
||||||
|
const baseAbbr = props.baseUnit?.abbreviation || props.product?.unit_of_measure?.abbreviation || '';
|
||||||
|
|
||||||
|
const base = {
|
||||||
|
unit_of_measure_id: null,
|
||||||
|
unit_name: baseName,
|
||||||
|
unit_abbreviation: baseAbbr,
|
||||||
|
unit_price: basePrice,
|
||||||
|
conversion_factor: 1,
|
||||||
|
label: `${baseName}${baseAbbr ? ` (${baseAbbr})` : ''}`,
|
||||||
|
priceLabel: `$${basePrice.toFixed(2)}`
|
||||||
|
};
|
||||||
|
|
||||||
|
const equivalenceOptions = props.equivalences.map(eq => ({
|
||||||
|
unit_of_measure_id: eq.unit_of_measure_id,
|
||||||
|
unit_name: eq.unit_name,
|
||||||
|
unit_abbreviation: eq.unit_abbreviation,
|
||||||
|
unit_price: parseFloat(eq.retail_price),
|
||||||
|
conversion_factor: parseFloat(eq.conversion_factor),
|
||||||
|
label: `${eq.unit_name}${eq.unit_abbreviation ? ` (${eq.unit_abbreviation})` : ''}`,
|
||||||
|
priceLabel: `$${parseFloat(eq.retail_price).toFixed(2)}`
|
||||||
|
}));
|
||||||
|
|
||||||
|
return [base, ...equivalenceOptions];
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Métodos */
|
||||||
|
const selectOption = (option) => {
|
||||||
|
selectedOption.value = option;
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmSelection = () => {
|
||||||
|
if (!selectedOption.value) return;
|
||||||
|
emit('confirm', {
|
||||||
|
unit_of_measure_id: selectedOption.value.unit_of_measure_id,
|
||||||
|
unit_price: selectedOption.value.unit_price,
|
||||||
|
unit_name: selectedOption.value.unit_of_measure_id ? selectedOption.value.unit_name : null,
|
||||||
|
});
|
||||||
|
selectedOption.value = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
selectedOption.value = null;
|
||||||
|
emit('close');
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Modal :show="show" max-width="sm" @close="handleClose">
|
||||||
|
<div class="p-6">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex items-center justify-between mb-5">
|
||||||
|
<div>
|
||||||
|
<h3 class="text-base font-bold text-gray-900 dark:text-gray-100">
|
||||||
|
Seleccionar unidad
|
||||||
|
</h3>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400 mt-0.5 truncate max-w-xs">
|
||||||
|
{{ product?.name }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
@click="handleClose"
|
||||||
|
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>
|
||||||
|
|
||||||
|
<!-- Opciones de unidad -->
|
||||||
|
<div class="space-y-2 mb-5">
|
||||||
|
<button
|
||||||
|
v-for="option in options"
|
||||||
|
:key="option.unit_of_measure_id ?? 'base'"
|
||||||
|
type="button"
|
||||||
|
@click="selectOption(option)"
|
||||||
|
:class="[
|
||||||
|
'w-full flex items-center justify-between p-3 rounded-lg border-2 text-left transition-all',
|
||||||
|
selectedOption?.unit_of_measure_id === option.unit_of_measure_id
|
||||||
|
? 'border-indigo-500 bg-indigo-50 dark:bg-indigo-950 dark:border-indigo-400'
|
||||||
|
: 'border-gray-200 hover:border-gray-300 dark:border-gray-700 dark:hover:border-gray-500'
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<!-- Check indicator -->
|
||||||
|
<div
|
||||||
|
:class="[
|
||||||
|
'w-4 h-4 rounded-full border-2 flex items-center justify-center flex-shrink-0',
|
||||||
|
selectedOption?.unit_of_measure_id === option.unit_of_measure_id
|
||||||
|
? 'border-indigo-500 bg-indigo-500'
|
||||||
|
: 'border-gray-300 dark:border-gray-600'
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-if="selectedOption?.unit_of_measure_id === option.unit_of_measure_id"
|
||||||
|
class="w-1.5 h-1.5 rounded-full bg-white"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Info -->
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
||||||
|
{{ option.label }}
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
v-if="option.unit_of_measure_id !== null"
|
||||||
|
class="text-xs text-gray-500 dark:text-gray-400 mt-0.5"
|
||||||
|
>
|
||||||
|
1 {{ option.unit_name }} = {{ option.conversion_factor }} {{ baseUnit?.abbreviation || '' }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Precio -->
|
||||||
|
<span class="text-sm font-bold text-gray-900 dark:text-gray-100 ml-2">
|
||||||
|
{{ option.priceLabel }}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Botones -->
|
||||||
|
<div class="flex items-center justify-end gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="handleClose"
|
||||||
|
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 dark:bg-gray-800 dark:text-gray-300 dark:border-gray-600 dark:hover:bg-gray-700"
|
||||||
|
>
|
||||||
|
Cancelar
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="confirmSelection"
|
||||||
|
:disabled="!selectedOption"
|
||||||
|
class="px-4 py-2 text-sm font-semibold text-white bg-indigo-600 rounded-lg hover:bg-indigo-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||||
|
>
|
||||||
|
Agregar al carrito
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</template>
|
||||||
@ -462,6 +462,7 @@ export default {
|
|||||||
returns: 'Devoluciones',
|
returns: 'Devoluciones',
|
||||||
clients: 'Clientes',
|
clients: 'Clientes',
|
||||||
suppliers: 'Proveedores',
|
suppliers: 'Proveedores',
|
||||||
|
unitMeasure: 'Unidades de medida',
|
||||||
clientTiers: 'Niveles de Clientes',
|
clientTiers: 'Niveles de Clientes',
|
||||||
billingRequests: 'Solicitudes de Facturación',
|
billingRequests: 'Solicitudes de Facturación',
|
||||||
warehouses: 'Almacenes',
|
warehouses: 'Almacenes',
|
||||||
|
|||||||
@ -75,6 +75,11 @@ onMounted(() => {
|
|||||||
name="pos.suppliers"
|
name="pos.suppliers"
|
||||||
to="pos.suppliers.index"
|
to="pos.suppliers.index"
|
||||||
/>
|
/>
|
||||||
|
<SubLink
|
||||||
|
icon="scale"
|
||||||
|
name="pos.unitMeasure"
|
||||||
|
to="pos.unitMeasure.index"
|
||||||
|
/>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
<Link
|
<Link
|
||||||
v-if="hasPermission('movements.index')"
|
v-if="hasPermission('movements.index')"
|
||||||
|
|||||||
@ -44,12 +44,6 @@ const suggestedPrice = computed(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
/** Métodos */
|
/** Métodos */
|
||||||
const calculateTax = () => {
|
|
||||||
if (form.retail_price && !form.tax) {
|
|
||||||
form.tax = (parseFloat(form.retail_price) * 0.16).toFixed(2);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleProductSelect = (product) => {
|
const handleProductSelect = (product) => {
|
||||||
if (selectedProducts.value.find(item => item.product.id === product.id)) {
|
if (selectedProducts.value.find(item => item.product.id === product.id)) {
|
||||||
Notify.warning('Este producto ya está agregado');
|
Notify.warning('Este producto ya está agregado');
|
||||||
@ -213,7 +207,6 @@ watch(() => props.show, (val) => {
|
|||||||
</label>
|
</label>
|
||||||
<FormInput
|
<FormInput
|
||||||
v-model.number="form.retail_price"
|
v-model.number="form.retail_price"
|
||||||
@blur="calculateTax"
|
|
||||||
type="number"
|
type="number"
|
||||||
step="0.01"
|
step="0.01"
|
||||||
min="0"
|
min="0"
|
||||||
|
|||||||
@ -46,12 +46,6 @@ const suggestedPrice = computed(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
/** Métodos */
|
/** Métodos */
|
||||||
const calculateTax = () => {
|
|
||||||
if (form.retail_price && !form.tax) {
|
|
||||||
form.tax = (parseFloat(form.retail_price) * 0.16).toFixed(2);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleProductSelect = (product) => {
|
const handleProductSelect = (product) => {
|
||||||
if (selectedProducts.value.find(item => item.product.id === product.id)) {
|
if (selectedProducts.value.find(item => item.product.id === product.id)) {
|
||||||
Notify.warning('Este producto ya está agregado');
|
Notify.warning('Este producto ya está agregado');
|
||||||
@ -72,7 +66,6 @@ const updateQuantity = (index, quantity) => {
|
|||||||
|
|
||||||
const useSuggestedPrice = () => {
|
const useSuggestedPrice = () => {
|
||||||
form.retail_price = suggestedPrice.value.toFixed(2);
|
form.retail_price = suggestedPrice.value.toFixed(2);
|
||||||
calculateTax();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateBundle = () => {
|
const updateBundle = () => {
|
||||||
@ -229,7 +222,6 @@ watch(() => props.bundle, (bundle) => {
|
|||||||
</label>
|
</label>
|
||||||
<FormInput
|
<FormInput
|
||||||
v-model.number="form.retail_price"
|
v-model.number="form.retail_price"
|
||||||
@blur="calculateTax"
|
|
||||||
type="number"
|
type="number"
|
||||||
step="0.01"
|
step="0.01"
|
||||||
min="0"
|
min="0"
|
||||||
|
|||||||
@ -4,6 +4,8 @@ import { useForm, apiURL } from '@Services/Api';
|
|||||||
import Modal from '@Holos/Modal.vue';
|
import Modal from '@Holos/Modal.vue';
|
||||||
import FormInput from '@Holos/Form/Input.vue';
|
import FormInput from '@Holos/Form/Input.vue';
|
||||||
import FormError from '@Holos/Form/Elements/Error.vue';
|
import FormError from '@Holos/Form/Elements/Error.vue';
|
||||||
|
import SelectUsoCfdi from '@Components/POS/CfdiSelector.vue';
|
||||||
|
import SelectRegimenFiscal from '@Components/POS/RegimenSelecto.vue';
|
||||||
|
|
||||||
/** Eventos */
|
/** Eventos */
|
||||||
const emit = defineEmits(['close', 'created']);
|
const emit = defineEmits(['close', 'created']);
|
||||||
@ -152,18 +154,10 @@ const closeModal = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- REGIMEN FISCAL-->
|
<!-- REGIMEN FISCAL-->
|
||||||
<div>
|
<SelectRegimenFiscal
|
||||||
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
|
v-model="form.regimen_fiscal"
|
||||||
REGIMEN FISCAL
|
:error="form.errors?.regimen_fiscal"
|
||||||
</label>
|
/>
|
||||||
<FormInput
|
|
||||||
v-model="form.regimen_fiscal"
|
|
||||||
type="text"
|
|
||||||
placeholder="Regimen Fiscal"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
<FormError :message="form.errors?.regimen_fiscal" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- CP FISCAL-->
|
<!-- CP FISCAL-->
|
||||||
<div>
|
<div>
|
||||||
@ -180,18 +174,10 @@ const closeModal = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- USO CFDI -->
|
<!-- USO CFDI -->
|
||||||
<div>
|
<SelectUsoCfdi
|
||||||
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
|
v-model="form.uso_cfdi"
|
||||||
USO DE CFDI
|
:error="form.errors?.uso_cfdi"
|
||||||
</label>
|
/>
|
||||||
<FormInput
|
|
||||||
v-model="form.uso_cdfi"
|
|
||||||
type="text"
|
|
||||||
placeholder="03 - Gastos en general"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
<FormError :message="form.errors?.uso_cdfi" />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Botones -->
|
<!-- Botones -->
|
||||||
|
|||||||
@ -5,6 +5,8 @@ import { useForm, apiURL } from '@Services/Api';
|
|||||||
import Modal from '@Holos/Modal.vue';
|
import Modal from '@Holos/Modal.vue';
|
||||||
import FormInput from '@Holos/Form/Input.vue';
|
import FormInput from '@Holos/Form/Input.vue';
|
||||||
import FormError from '@Holos/Form/Elements/Error.vue';
|
import FormError from '@Holos/Form/Elements/Error.vue';
|
||||||
|
import SelectUsoCfdi from '@Components/POS/CfdiSelector.vue';
|
||||||
|
import SelectRegimenFiscal from '@Components/POS/RegimenSelecto.vue';
|
||||||
|
|
||||||
/** Eventos */
|
/** Eventos */
|
||||||
const emit = defineEmits(['close', 'updated']);
|
const emit = defineEmits(['close', 'updated']);
|
||||||
@ -168,21 +170,7 @@ watch(() => props.client, (newClient) => {
|
|||||||
/>
|
/>
|
||||||
<FormError :message="form.errors?.razon_social" />
|
<FormError :message="form.errors?.razon_social" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- REGIMEN FISCAL-->
|
|
||||||
<div>
|
|
||||||
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
|
|
||||||
REGIMEN FISCAL
|
|
||||||
</label>
|
|
||||||
<FormInput
|
|
||||||
v-model="form.regimen_fiscal"
|
|
||||||
type="text"
|
|
||||||
placeholder="Régimen fiscal del cliente"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
<FormError :message="form.errors?.regimen_fiscal" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- CP FISCAL-->
|
<!-- CP FISCAL-->
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
|
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
|
||||||
@ -197,19 +185,18 @@ watch(() => props.client, (newClient) => {
|
|||||||
<FormError :message="form.errors?.cp_fiscal" />
|
<FormError :message="form.errors?.cp_fiscal" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- REGIMEN FISCAL-->
|
||||||
|
<SelectRegimenFiscal
|
||||||
|
v-model="form.regimen_fiscal"
|
||||||
|
:error="form.errors?.regimen_fiscal"
|
||||||
|
/>
|
||||||
|
|
||||||
|
|
||||||
<!-- USO CFDI -->
|
<!-- USO CFDI -->
|
||||||
<div>
|
<SelectUsoCfdi
|
||||||
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
|
v-model="form.uso_cfdi"
|
||||||
USO CFDI
|
:error="form.errors?.uso_cfdi"
|
||||||
</label>
|
/>
|
||||||
<FormInput
|
|
||||||
v-model="form.uso_cfdi"
|
|
||||||
type="text"
|
|
||||||
placeholder="03 - GASTOS EN GENERAL"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
<FormError :message="form.errors?.uso_cfdi" />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Botones -->
|
<!-- Botones -->
|
||||||
|
|||||||
@ -2,6 +2,10 @@
|
|||||||
import { onMounted, ref } from 'vue';
|
import { onMounted, ref } from 'vue';
|
||||||
import { useSearcher, apiURL } from '@Services/Api';
|
import { useSearcher, apiURL } from '@Services/Api';
|
||||||
import { can } from './Module.js';
|
import { can } from './Module.js';
|
||||||
|
import { regimenFiscalOptions, usoCfdiOptions } from '@/utils/fiscalData';
|
||||||
|
|
||||||
|
const regimenFiscalLabel = (value) => regimenFiscalOptions.find(o => o.value === value)?.label ?? value;
|
||||||
|
const usoCfdiLabel = (value) => usoCfdiOptions.find(o => o.value === value)?.label ?? value;
|
||||||
|
|
||||||
import SearcherHead from '@Holos/Searcher.vue';
|
import SearcherHead from '@Holos/Searcher.vue';
|
||||||
import ExcelModal from '@Components/POS/ExcelClient.vue';
|
import ExcelModal from '@Components/POS/ExcelClient.vue';
|
||||||
@ -198,13 +202,13 @@ onMounted(() => {
|
|||||||
<p class="text-sm text-gray-700 dark:text-gray-300">{{ client.razon_social }}</p>
|
<p class="text-sm text-gray-700 dark:text-gray-300">{{ client.razon_social }}</p>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-6 py-4 text-center">
|
<td class="px-6 py-4 text-center">
|
||||||
<p class="text-sm text-gray-700 dark:text-gray-300">{{ client.regimen_fiscal }}</p>
|
<p class="text-sm text-gray-700 dark:text-gray-300">{{ regimenFiscalLabel(client.regimen_fiscal) }}</p>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-6 py-4 text-center">
|
<td class="px-6 py-4 text-center">
|
||||||
<p class="text-sm text-gray-700 dark:text-gray-300">{{ client.cp_fiscal }}</p>
|
<p class="text-sm text-gray-700 dark:text-gray-300">{{ client.cp_fiscal }}</p>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-6 py-4 text-center">
|
<td class="px-6 py-4 text-center">
|
||||||
<p class="text-sm text-gray-700 dark:text-gray-300">{{ client.uso_cfdi }}</p>
|
<p class="text-sm text-gray-700 dark:text-gray-300">{{ usoCfdiLabel(client.uso_cfdi) }}</p>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-6 py-4 whitespace-nowrap text-center">
|
<td class="px-6 py-4 whitespace-nowrap text-center">
|
||||||
<div class="flex items-center justify-center gap-2">
|
<div class="flex items-center justify-center gap-2">
|
||||||
|
|||||||
@ -8,6 +8,8 @@ import GoogleIcon from '@Shared/GoogleIcon.vue';
|
|||||||
import Loader from '@Shared/Loader.vue';
|
import Loader from '@Shared/Loader.vue';
|
||||||
import Input from '@Holos/Form/Input.vue';
|
import Input from '@Holos/Form/Input.vue';
|
||||||
import PrimaryButton from '@Holos/Button/Primary.vue';
|
import PrimaryButton from '@Holos/Button/Primary.vue';
|
||||||
|
import SelectUsoCfdi from '@Components/POS/CfdiSelector.vue';
|
||||||
|
import SelectRegimenFiscal from '@Components/POS/RegimenSelector.vue';
|
||||||
|
|
||||||
/** Definidores */
|
/** Definidores */
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
@ -43,32 +45,6 @@ const paymentMethods = [
|
|||||||
{ value: 'debit_card', label: 'Tarjeta de Débito' }
|
{ value: 'debit_card', label: 'Tarjeta de Débito' }
|
||||||
];
|
];
|
||||||
|
|
||||||
const usoCfdiOptions = [
|
|
||||||
{ value: 'G01', label: 'G01 - Adquisición de mercancías' },
|
|
||||||
{ value: 'G02', label: 'G02 - Devoluciones, descuentos o bonificaciones' },
|
|
||||||
{ value: 'G03', label: 'G03 - Gastos en general' },
|
|
||||||
{ value: 'I01', label: 'I01 - Construcciones' },
|
|
||||||
{ value: 'I02', label: 'I02 - Mobiliario y equipo de oficina por inversiones' },
|
|
||||||
{ value: 'I03', label: 'I03 - Equipo de transporte' },
|
|
||||||
{ value: 'I04', label: 'I04 - Equipo de computo y accesorios' },
|
|
||||||
{ value: 'I05', label: 'I05 - Dados, troqueles, moldes, matrices y herramental' },
|
|
||||||
{ value: 'I06', label: 'I06 - Comunicaciones telefónicas' },
|
|
||||||
{ value: 'I07', label: 'I07 - Comunicaciones satelitales' },
|
|
||||||
{ value: 'I08', label: 'I08 - Otra maquinaria y equipo' },
|
|
||||||
{ value: 'S01', label: 'S01 - Sin efectos fiscales' }
|
|
||||||
];
|
|
||||||
|
|
||||||
const regimenFiscalOptions = [
|
|
||||||
{ value: '601', label: '601 - General de Ley Personas Morales' },
|
|
||||||
{ value: '603', label: '603 - Personas Morales con Fines no Lucrativos' },
|
|
||||||
{ value: '610', label: '610 - Residentes en el Extranjero sin Establecimiento Permanente en México' },
|
|
||||||
{ value: '620', label: '620 - Sociedades Cooperativas de Producción que optan por diferir sus ingresos' },
|
|
||||||
{ value: '622', label: '622 - Actividades Agrícolas, Ganaderas, Silvícolas y Pesqueras' },
|
|
||||||
{ value: '623', label: '623 - Opcional para Grupos de Sociedades' },
|
|
||||||
{ value: '624', label: '624 - Coordinados' },
|
|
||||||
{ value: '626', label: '626 - Régimen Simplificado de Confianza' }
|
|
||||||
];
|
|
||||||
|
|
||||||
const paymentMethodLabel = computed(() => {
|
const paymentMethodLabel = computed(() => {
|
||||||
const method = paymentMethods.find(m => saleData.value?.payment_method?.includes(m.value));
|
const method = paymentMethods.find(m => saleData.value?.payment_method?.includes(m.value));
|
||||||
return method?.label || saleData.value?.payment_method || 'N/A';
|
return method?.label || saleData.value?.payment_method || 'N/A';
|
||||||
@ -93,16 +69,9 @@ const canRequestInvoice = computed(() => {
|
|||||||
return latestRequest.value.status === 'rejected';
|
return latestRequest.value.status === 'rejected';
|
||||||
});
|
});
|
||||||
|
|
||||||
/** Helpers para mostrar labels legibles */
|
/** Helpers */
|
||||||
const getRegimenFiscalLabel = (value) => {
|
const getRegimenFiscalLabel = (value) => value || 'No registrado';
|
||||||
const option = regimenFiscalOptions.find(o => o.value === value);
|
const getUsoCfdiLabel = (value) => value || 'No registrado';
|
||||||
return option ? option.label : value || 'No registrado';
|
|
||||||
};
|
|
||||||
|
|
||||||
const getUsoCfdiLabel = (value) => {
|
|
||||||
const option = usoCfdiOptions.find(o => o.value === value);
|
|
||||||
return option ? option.label : value || 'No registrado';
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Métodos */
|
/** Métodos */
|
||||||
const fetchSaleData = () => {
|
const fetchSaleData = () => {
|
||||||
@ -113,8 +82,6 @@ const fetchSaleData = () => {
|
|||||||
.then(({ data }) => {
|
.then(({ data }) => {
|
||||||
if (data.status === 'success') {
|
if (data.status === 'success') {
|
||||||
saleData.value = data.data.sale;
|
saleData.value = data.data.sale;
|
||||||
|
|
||||||
// Si la venta ya tiene un cliente asociado, cargar sus datos
|
|
||||||
if (data.data.client) {
|
if (data.data.client) {
|
||||||
clientData.value = data.data.client;
|
clientData.value = data.data.client;
|
||||||
fillFormWithClient(data.data.client);
|
fillFormWithClient(data.data.client);
|
||||||
@ -136,9 +103,6 @@ const fetchSaleData = () => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Llenar el formulario con los datos del cliente
|
|
||||||
*/
|
|
||||||
const fillFormWithClient = (client) => {
|
const fillFormWithClient = (client) => {
|
||||||
form.value = {
|
form.value = {
|
||||||
name: client.name || '',
|
name: client.name || '',
|
||||||
@ -153,9 +117,6 @@ const fillFormWithClient = (client) => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Buscar cliente por RFC
|
|
||||||
*/
|
|
||||||
const searchClientByRfc = () => {
|
const searchClientByRfc = () => {
|
||||||
const rfc = rfcSearch.value?.trim().toUpperCase();
|
const rfc = rfcSearch.value?.trim().toUpperCase();
|
||||||
|
|
||||||
@ -174,7 +135,6 @@ const searchClientByRfc = () => {
|
|||||||
|
|
||||||
window.axios.get(apiURL(`facturacion/check-rfc?rfc=${rfc}`))
|
window.axios.get(apiURL(`facturacion/check-rfc?rfc=${rfc}`))
|
||||||
.then(({ data }) => {
|
.then(({ data }) => {
|
||||||
// La respuesta viene: { status: 'success', data: { exists: true, client: {...} } }
|
|
||||||
if (data.status === 'success' && data.data?.exists && data.data?.client) {
|
if (data.status === 'success' && data.data?.exists && data.data?.client) {
|
||||||
clientData.value = data.data.client;
|
clientData.value = data.data.client;
|
||||||
fillFormWithClient(data.data.client);
|
fillFormWithClient(data.data.client);
|
||||||
@ -195,9 +155,6 @@ const searchClientByRfc = () => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Manejar Enter en el input de búsqueda
|
|
||||||
*/
|
|
||||||
const handleSearchKeypress = (event) => {
|
const handleSearchKeypress = (event) => {
|
||||||
if (event.key === 'Enter') {
|
if (event.key === 'Enter') {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@ -205,9 +162,6 @@ const handleSearchKeypress = (event) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Limpiar datos del cliente y volver al formulario limpio
|
|
||||||
*/
|
|
||||||
const clearFoundClient = () => {
|
const clearFoundClient = () => {
|
||||||
clientData.value = null;
|
clientData.value = null;
|
||||||
rfcSearchError.value = '';
|
rfcSearchError.value = '';
|
||||||
@ -230,7 +184,7 @@ const submitForm = () => {
|
|||||||
formErrors.value = {};
|
formErrors.value = {};
|
||||||
|
|
||||||
window.axios.post(apiURL(`facturacion/${invoiceNumber.value}`), form.value)
|
window.axios.post(apiURL(`facturacion/${invoiceNumber.value}`), form.value)
|
||||||
.then(({ data }) => {
|
.then(() => {
|
||||||
submitted.value = true;
|
submitted.value = true;
|
||||||
})
|
})
|
||||||
.catch(({ response }) => {
|
.catch(({ response }) => {
|
||||||
@ -247,7 +201,6 @@ const submitForm = () => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Ciclos */
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
fetchSaleData();
|
fetchSaleData();
|
||||||
});
|
});
|
||||||
@ -649,30 +602,11 @@ onMounted(() => {
|
|||||||
:onError="formErrors.razon_social"
|
:onError="formErrors.razon_social"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Régimen Fiscal Select -->
|
<SelectRegimenFiscal
|
||||||
<div>
|
v-model="form.regimen_fiscal"
|
||||||
<label for="regimen_fiscal" class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
|
:error="formErrors.regimen_fiscal?.[0]"
|
||||||
Régimen Fiscal *
|
required
|
||||||
</label>
|
/>
|
||||||
<select
|
|
||||||
v-model="form.regimen_fiscal"
|
|
||||||
id="regimen_fiscal"
|
|
||||||
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-700 dark:border-gray-600 dark:text-gray-100"
|
|
||||||
:class="{ 'border-red-500': formErrors.regimen_fiscal }"
|
|
||||||
>
|
|
||||||
<option value="" disabled>Seleccionar régimen fiscal</option>
|
|
||||||
<option
|
|
||||||
v-for="option in regimenFiscalOptions"
|
|
||||||
:key="option.value"
|
|
||||||
:value="option.value"
|
|
||||||
>
|
|
||||||
{{ option.label }}
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
<p v-if="formErrors.regimen_fiscal" class="mt-1 text-xs text-red-600 dark:text-red-400">
|
|
||||||
{{ formErrors.regimen_fiscal[0] }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Input
|
<Input
|
||||||
v-model="form.cp_fiscal"
|
v-model="form.cp_fiscal"
|
||||||
@ -683,30 +617,11 @@ onMounted(() => {
|
|||||||
:onError="formErrors.cp_fiscal"
|
:onError="formErrors.cp_fiscal"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Uso CFDI Select -->
|
<SelectUsoCfdi
|
||||||
<div>
|
v-model="form.uso_cfdi"
|
||||||
<label for="uso_cfdi" class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
|
:error="formErrors.uso_cfdi?.[0]"
|
||||||
Uso de CFDI *
|
required
|
||||||
</label>
|
/>
|
||||||
<select
|
|
||||||
v-model="form.uso_cfdi"
|
|
||||||
id="uso_cfdi"
|
|
||||||
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-700 dark:border-gray-600 dark:text-gray-100"
|
|
||||||
:class="{ 'border-red-500': formErrors.uso_cfdi }"
|
|
||||||
>
|
|
||||||
<option value="" disabled>Seleccionar uso de CFDI</option>
|
|
||||||
<option
|
|
||||||
v-for="option in usoCfdiOptions"
|
|
||||||
:key="option.value"
|
|
||||||
:value="option.value"
|
|
||||||
>
|
|
||||||
{{ option.label }}
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
<p v-if="formErrors.uso_cfdi" class="mt-1 text-xs text-red-600 dark:text-red-400">
|
|
||||||
{{ formErrors.uso_cfdi[0] }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="md:col-span-2">
|
<div class="md:col-span-2">
|
||||||
<Input
|
<Input
|
||||||
|
|||||||
@ -91,7 +91,10 @@ const validateSerialsAndUnit = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const createProduct = () => {
|
const createProduct = () => {
|
||||||
form.post(apiURL('inventario'), {
|
form.transform((data) => ({
|
||||||
|
...data,
|
||||||
|
track_serials: selectedUnit.value ? !selectedUnit.value.allows_decimals && !!data.track_serials : false
|
||||||
|
})).post(apiURL('inventario'), {
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
Notify.success('Producto creado exitosamente');
|
Notify.success('Producto creado exitosamente');
|
||||||
emit('created');
|
emit('created');
|
||||||
@ -167,10 +170,9 @@ watch(() => form.track_serials, () => {
|
|||||||
</label>
|
</label>
|
||||||
<FormInput
|
<FormInput
|
||||||
v-model="form.key_sat"
|
v-model="form.key_sat"
|
||||||
type="text"
|
type="string"
|
||||||
placeholder="Clave SAT del producto"
|
placeholder="Clave SAT del producto"
|
||||||
required
|
maxlength="8"
|
||||||
maxlength="9"
|
|
||||||
/>
|
/>
|
||||||
<FormError :message="form.errors?.key_sat" />
|
<FormError :message="form.errors?.key_sat" />
|
||||||
</div>
|
</div>
|
||||||
@ -251,25 +253,6 @@ watch(() => form.track_serials, () => {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Track Serials -->
|
|
||||||
<div class="col-span-2">
|
|
||||||
<label class="flex items-center cursor-pointer">
|
|
||||||
<input
|
|
||||||
v-model="form.track_serials"
|
|
||||||
type="checkbox"
|
|
||||||
:disabled="!canUseSerials"
|
|
||||||
class="w-4 h-4 text-indigo-600 border-gray-300 rounded focus:ring-indigo-500 dark:border-gray-600 dark:bg-gray-800 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
||||||
/>
|
|
||||||
<span class="ml-2 text-sm text-gray-700 dark:text-gray-300">
|
|
||||||
Rastrear números de serie
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
<p v-if="selectedUnit && selectedUnit.allows_decimals" class="mt-1 text-xs text-amber-600 dark:text-amber-400">
|
|
||||||
No se pueden usar números de serie con esta unidad de medida.
|
|
||||||
</p>
|
|
||||||
<FormError :message="form.errors?.track_serials" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Precio de Venta -->
|
<!-- Precio de Venta -->
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
|
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, watch, computed } from 'vue';
|
import { ref, watch, computed } from 'vue';
|
||||||
import { useRouter } from 'vue-router';
|
import { useRouter } from 'vue-router';
|
||||||
import { useForm, apiURL } from '@Services/Api';
|
import { useForm, useApi, apiURL } from '@Services/Api';
|
||||||
|
|
||||||
import Modal from '@Holos/Modal.vue';
|
import Modal from '@Holos/Modal.vue';
|
||||||
import FormInput from '@Holos/Form/Input.vue';
|
import FormInput from '@Holos/Form/Input.vue';
|
||||||
@ -22,8 +22,18 @@ const props = defineProps({
|
|||||||
/** Estado */
|
/** Estado */
|
||||||
const categories = ref([]);
|
const categories = ref([]);
|
||||||
const units = ref([]);
|
const units = ref([]);
|
||||||
|
const activeTab = ref('general');
|
||||||
|
|
||||||
/** Formulario */
|
// 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({
|
const form = useForm({
|
||||||
name: '',
|
name: '',
|
||||||
key_sat: '',
|
key_sat: '',
|
||||||
@ -36,6 +46,13 @@ const form = useForm({
|
|||||||
track_serials: false
|
track_serials: false
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** Formulario de equivalencia */
|
||||||
|
const eqForm = useForm({
|
||||||
|
unit_of_measure_id: null,
|
||||||
|
conversion_factor: '',
|
||||||
|
retail_price: ''
|
||||||
|
});
|
||||||
|
|
||||||
/** Computed */
|
/** Computed */
|
||||||
const selectedUnit = computed(() => {
|
const selectedUnit = computed(() => {
|
||||||
if (!form.unit_of_measure_id) return null;
|
if (!form.unit_of_measure_id) return null;
|
||||||
@ -44,7 +61,23 @@ const selectedUnit = computed(() => {
|
|||||||
|
|
||||||
const canUseSerials = computed(() => {
|
const canUseSerials = computed(() => {
|
||||||
if (!selectedUnit.value) return true;
|
if (!selectedUnit.value) return true;
|
||||||
return !selectedUnit.value.allows_decimals;
|
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;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
return true;
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
/** Métodos */
|
/** Métodos */
|
||||||
@ -82,6 +115,24 @@ const loadUnits = async () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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 = () => {
|
const validateSerialsAndUnit = () => {
|
||||||
if (form.track_serials && selectedUnit.value && selectedUnit.value.allows_decimals) {
|
if (form.track_serials && selectedUnit.value && selectedUnit.value.allows_decimals) {
|
||||||
Notify.warning('No se pueden usar números de serie con esta unidad de medida.');
|
Notify.warning('No se pueden usar números de serie con esta unidad de medida.');
|
||||||
@ -90,7 +141,14 @@ const validateSerialsAndUnit = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const updateProduct = () => {
|
const updateProduct = () => {
|
||||||
form.put(apiURL(`inventario/${props.product.id}`), {
|
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
|
||||||
|
})).put(apiURL(`inventario/${props.product.id}`), {
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
Notify.success('Producto actualizado exitosamente');
|
Notify.success('Producto actualizado exitosamente');
|
||||||
emit('updated');
|
emit('updated');
|
||||||
@ -104,14 +162,100 @@ const updateProduct = () => {
|
|||||||
|
|
||||||
const closeModal = () => {
|
const closeModal = () => {
|
||||||
form.reset();
|
form.reset();
|
||||||
|
activeTab.value = 'general';
|
||||||
|
showEquivalenceForm.value = false;
|
||||||
|
editingEquivalence.value = null;
|
||||||
|
equivalences.value = [];
|
||||||
|
baseUnit.value = null;
|
||||||
emit('close');
|
emit('close');
|
||||||
};
|
};
|
||||||
|
|
||||||
const openSerials = () => {
|
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();
|
closeModal();
|
||||||
router.push({ name: 'pos.inventory.serials', params: { id: props.product.id } });
|
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 */
|
/** Observadores */
|
||||||
watch(() => props.product, (newProduct) => {
|
watch(() => props.product, (newProduct) => {
|
||||||
if (newProduct) {
|
if (newProduct) {
|
||||||
@ -124,7 +268,9 @@ watch(() => props.product, (newProduct) => {
|
|||||||
form.cost = parseFloat(newProduct.price?.cost || 0);
|
form.cost = parseFloat(newProduct.price?.cost || 0);
|
||||||
form.retail_price = parseFloat(newProduct.price?.retail_price || 0);
|
form.retail_price = parseFloat(newProduct.price?.retail_price || 0);
|
||||||
form.tax = parseFloat(newProduct.price?.tax || 16);
|
form.tax = parseFloat(newProduct.price?.tax || 16);
|
||||||
form.track_serials = newProduct.track_serials || false;
|
|
||||||
|
const serialCount = Number(newProduct.serials_count || 0);
|
||||||
|
form.track_serials = !!newProduct.track_serials || serialCount > 0;
|
||||||
}
|
}
|
||||||
}, { immediate: true });
|
}, { immediate: true });
|
||||||
|
|
||||||
@ -132,6 +278,7 @@ watch(() => props.show, (newValue) => {
|
|||||||
if (newValue) {
|
if (newValue) {
|
||||||
loadCategories();
|
loadCategories();
|
||||||
loadUnits();
|
loadUnits();
|
||||||
|
activeTab.value = 'general';
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -139,8 +286,10 @@ watch(() => form.unit_of_measure_id, () => {
|
|||||||
validateSerialsAndUnit();
|
validateSerialsAndUnit();
|
||||||
});
|
});
|
||||||
|
|
||||||
watch(() => form.track_serials, () => {
|
watch(activeTab, (tab) => {
|
||||||
validateSerialsAndUnit();
|
if (tab === 'equivalences' && props.product?.id) {
|
||||||
|
loadEquivalences();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@ -148,7 +297,7 @@ watch(() => form.track_serials, () => {
|
|||||||
<Modal :show="show" max-width="md" @close="closeModal">
|
<Modal :show="show" max-width="md" @close="closeModal">
|
||||||
<div class="p-6">
|
<div class="p-6">
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<div class="flex items-center justify-between mb-6">
|
<div class="flex items-center justify-between mb-4">
|
||||||
<h3 class="text-lg font-bold text-gray-900 dark:text-gray-100">
|
<h3 class="text-lg font-bold text-gray-900 dark:text-gray-100">
|
||||||
Editar Producto
|
Editar Producto
|
||||||
</h3>
|
</h3>
|
||||||
@ -162,200 +311,420 @@ watch(() => form.track_serials, () => {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Formulario -->
|
<!-- Tabs -->
|
||||||
<form @submit.prevent="updateProduct" class="space-y-4">
|
<div class="flex border-b border-gray-200 dark:border-gray-700 mb-5">
|
||||||
<div class="grid grid-cols-2 gap-4">
|
<button
|
||||||
<!-- Nombre -->
|
@click="activeTab = 'general'"
|
||||||
<div class="col-span-2">
|
:class="[
|
||||||
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
|
'px-4 py-2 text-sm font-medium border-b-2 transition-colors',
|
||||||
NOMBRE
|
activeTab === 'general'
|
||||||
</label>
|
? 'border-indigo-500 text-indigo-600 dark:text-indigo-400'
|
||||||
<FormInput
|
: 'border-transparent text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'
|
||||||
v-model="form.name"
|
]"
|
||||||
type="text"
|
>
|
||||||
placeholder="Nombre del producto"
|
Información General
|
||||||
required
|
</button>
|
||||||
/>
|
<button
|
||||||
<FormError :message="form.errors?.name" />
|
v-if="canHaveEquivalences"
|
||||||
</div>
|
@click="activeTab = 'equivalences'"
|
||||||
|
:class="[
|
||||||
<!-- Clave SAT -->
|
'px-4 py-2 text-sm font-medium border-b-2 transition-colors flex items-center gap-1.5',
|
||||||
<div class="col-span-2">
|
activeTab === 'equivalences'
|
||||||
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
|
? 'border-indigo-500 text-indigo-600 dark:text-indigo-400'
|
||||||
CLAVE SAT
|
: 'border-transparent text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'
|
||||||
</label>
|
]"
|
||||||
<FormInput
|
>
|
||||||
v-model="form.key_sat"
|
Equivalencias
|
||||||
type="number"
|
<span
|
||||||
placeholder="Clave SAT del producto"
|
v-if="equivalences.length > 0"
|
||||||
maxlength="9"
|
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"
|
||||||
/>
|
|
||||||
<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">
|
|
||||||
CATEGORÍA
|
|
||||||
</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
|
|
||||||
>
|
|
||||||
<option value="">Seleccionar categoría</option>
|
|
||||||
<option
|
|
||||||
v-for="category in categories"
|
|
||||||
:key="category.id"
|
|
||||||
:value="category.id"
|
|
||||||
>
|
|
||||||
{{ category.name }}
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
<FormError :message="form.errors?.category_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"
|
|
||||||
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="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>
|
|
||||||
|
|
||||||
<!-- Track Serials -->
|
|
||||||
<div class="col-span-2">
|
|
||||||
<label class="flex items-center cursor-pointer">
|
|
||||||
<input
|
|
||||||
v-model="form.track_serials"
|
|
||||||
type="checkbox"
|
|
||||||
:disabled="!canUseSerials"
|
|
||||||
class="w-4 h-4 text-indigo-600 border-gray-300 rounded focus:ring-indigo-500 dark:border-gray-600 dark:bg-gray-800 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
||||||
/>
|
|
||||||
<span class="ml-2 text-sm text-gray-700 dark:text-gray-300">
|
|
||||||
Rastrear números de serie
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
<p v-if="selectedUnit && selectedUnit.allows_decimals" class="mt-1 text-xs text-amber-600 dark:text-amber-400">
|
|
||||||
No se pueden usar números de serie con esta unidad de medida.
|
|
||||||
</p>
|
|
||||||
<FormError :message="form.errors?.track_serials" />
|
|
||||||
</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" />
|
{{ equivalences.length }}
|
||||||
Gestionar Seriales
|
</span>
|
||||||
</button>
|
</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="selectedUnit?.allows_decimals ? `No disponible: ${selectedUnit.name} permite decimales` : 'Selecciona una unidad de medida'">
|
<div
|
||||||
<GoogleIcon name="qr_code_2" class="text-lg opacity-50" />
|
v-else
|
||||||
Gestionar Seriales
|
class="px-4 py-2 text-sm font-medium border-b-2 border-transparent text-gray-300 dark:text-gray-600 cursor-not-allowed"
|
||||||
</div>
|
title="No disponible para productos con rastreo de seriales"
|
||||||
<div class="flex items-center gap-3">
|
>
|
||||||
<button
|
Equivalencias
|
||||||
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>
|
</div>
|
||||||
</form>
|
</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="number"
|
||||||
|
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">
|
||||||
|
CATEGORÍA
|
||||||
|
</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
|
||||||
|
>
|
||||||
|
<option value="">Seleccionar categoría</option>
|
||||||
|
<option
|
||||||
|
v-for="category in categories"
|
||||||
|
:key="category.id"
|
||||||
|
:value="category.id"
|
||||||
|
>
|
||||||
|
{{ category.name }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<FormError :message="form.errors?.category_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"
|
||||||
|
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="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.001"
|
||||||
|
step="0.001"
|
||||||
|
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>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@ -372,6 +372,9 @@ onMounted(() => {
|
|||||||
>
|
>
|
||||||
{{ model.stock }}
|
{{ model.stock }}
|
||||||
</span>
|
</span>
|
||||||
|
<p v-if="model.unit_of_measure" class="text-xs text-gray-400 dark:text-gray-500 mt-0.5">
|
||||||
|
{{ model.unit_of_measure.abbreviation }}
|
||||||
|
</p>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-6 py-4 whitespace-nowrap text-center">
|
<td class="px-6 py-4 whitespace-nowrap text-center">
|
||||||
<div class="text-sm">
|
<div class="text-sm">
|
||||||
|
|||||||
@ -218,9 +218,9 @@ watch(() => props.show, (isShown) => {
|
|||||||
// Cargar datos del movimiento
|
// Cargar datos del movimiento
|
||||||
form.quantity = props.movement.quantity || 0;
|
form.quantity = props.movement.quantity || 0;
|
||||||
form.unit_cost = props.movement.unit_cost || 0;
|
form.unit_cost = props.movement.unit_cost || 0;
|
||||||
form.supplier_id = props.movement.supplier_id || null;
|
|
||||||
form.invoice_reference = props.movement.invoice_reference || '';
|
form.invoice_reference = props.movement.invoice_reference || '';
|
||||||
form.notes = props.movement.notes || '';
|
form.notes = props.movement.notes || '';
|
||||||
|
form.supplier_id = props.movement.supplier_id || null;
|
||||||
|
|
||||||
// Cargar números de serie si existen
|
// Cargar números de serie si existen
|
||||||
if (props.movement.serials && props.movement.serials.length > 0) {
|
if (props.movement.serials && props.movement.serials.length > 0) {
|
||||||
|
|||||||
@ -33,6 +33,9 @@ const productSuggestions = ref([]);
|
|||||||
const showProductSuggestions = ref(false);
|
const showProductSuggestions = ref(false);
|
||||||
const currentSearchIndex = ref(null); // Índice del producto que se está buscando
|
const currentSearchIndex = ref(null); // Índice del producto que se está buscando
|
||||||
|
|
||||||
|
// Cache de equivalencias por producto
|
||||||
|
const equivalencesCache = ref({});
|
||||||
|
|
||||||
const api = useApi();
|
const api = useApi();
|
||||||
|
|
||||||
/** Formulario */
|
/** Formulario */
|
||||||
@ -92,6 +95,34 @@ const loadData = () => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const loadEquivalencesForProduct = async (productId) => {
|
||||||
|
if (equivalencesCache.value[productId]) {
|
||||||
|
return equivalencesCache.value[productId];
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const response = await fetch(apiURL(`inventario/${productId}/equivalencias`), {
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${sessionStorage.token}`,
|
||||||
|
'Accept': 'application/json'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const json = await response.json();
|
||||||
|
const result = {
|
||||||
|
equivalences: (json.data?.equivalences || []).filter(e => e.is_active),
|
||||||
|
base_unit: json.data?.base_unit || null
|
||||||
|
};
|
||||||
|
equivalencesCache.value[productId] = result;
|
||||||
|
return result;
|
||||||
|
} catch {
|
||||||
|
return { equivalences: [], base_unit: null };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getSelectedEquivalence = (item) => {
|
||||||
|
if (!item.selected_unit_id || !item.equivalences?.length) return null;
|
||||||
|
return item.equivalences.find(e => e.unit_of_measure_id === item.selected_unit_id) || null;
|
||||||
|
};
|
||||||
|
|
||||||
const addProduct = () => {
|
const addProduct = () => {
|
||||||
selectedProducts.value.push({
|
selectedProducts.value.push({
|
||||||
inventory_id: '',
|
inventory_id: '',
|
||||||
@ -102,8 +133,12 @@ const addProduct = () => {
|
|||||||
track_serials: false,
|
track_serials: false,
|
||||||
unit_of_measure: null,
|
unit_of_measure: null,
|
||||||
allows_decimals: false,
|
allows_decimals: false,
|
||||||
serial_numbers_list: [{ serial_number: '', locked: false }], // Inputs individuales de seriales
|
serial_numbers_list: [{ serial_number: '', locked: false }],
|
||||||
serial_validation_error: ''
|
serial_validation_error: '',
|
||||||
|
// Equivalencias
|
||||||
|
equivalences: [],
|
||||||
|
base_unit: null,
|
||||||
|
selected_unit_id: null,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -170,18 +205,27 @@ const searchProduct = () => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const selectProduct = (product) => {
|
const selectProduct = async (product) => {
|
||||||
if (currentSearchIndex.value !== null) {
|
if (currentSearchIndex.value !== null) {
|
||||||
selectedProducts.value[currentSearchIndex.value].inventory_id = product.id;
|
const item = selectedProducts.value[currentSearchIndex.value];
|
||||||
selectedProducts.value[currentSearchIndex.value].product_name = product.name;
|
item.inventory_id = product.id;
|
||||||
selectedProducts.value[currentSearchIndex.value].product_sku = product.sku;
|
item.product_name = product.name;
|
||||||
selectedProducts.value[currentSearchIndex.value].track_serials = product.track_serials || false;
|
item.product_sku = product.sku;
|
||||||
selectedProducts.value[currentSearchIndex.value].unit_of_measure = product.unit_of_measure || null;
|
item.track_serials = product.track_serials || false;
|
||||||
selectedProducts.value[currentSearchIndex.value].allows_decimals = product.unit_of_measure?.allows_decimals || false;
|
item.unit_of_measure = product.unit_of_measure || null;
|
||||||
|
item.allows_decimals = product.unit_of_measure?.allows_decimals || false;
|
||||||
|
item.selected_unit_id = null;
|
||||||
|
|
||||||
// Limpiar seriales si la unidad permite decimales
|
// Limpiar seriales si la unidad permite decimales
|
||||||
if (product.unit_of_measure?.allows_decimals) {
|
if (product.unit_of_measure?.allows_decimals) {
|
||||||
selectedProducts.value[currentSearchIndex.value].serial_numbers_list = [{ serial_number: '', locked: false }];
|
item.serial_numbers_list = [{ serial_number: '', locked: false }];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cargar equivalencias (solo si no tiene seriales)
|
||||||
|
if (!product.track_serials) {
|
||||||
|
const { equivalences, base_unit } = await loadEquivalencesForProduct(product.id);
|
||||||
|
item.equivalences = equivalences;
|
||||||
|
item.base_unit = base_unit;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -223,9 +267,14 @@ const createEntry = () => {
|
|||||||
inventory_id: item.inventory_id,
|
inventory_id: item.inventory_id,
|
||||||
quantity: Number(item.quantity),
|
quantity: Number(item.quantity),
|
||||||
unit_cost: Number(item.unit_cost),
|
unit_cost: Number(item.unit_cost),
|
||||||
serial_numbers: [] // Inicializar siempre como array vacío
|
serial_numbers: []
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Incluir unidad de equivalencia si se seleccionó una distinta a la base
|
||||||
|
if (item.selected_unit_id) {
|
||||||
|
productData.unit_of_measure_id = item.selected_unit_id;
|
||||||
|
}
|
||||||
|
|
||||||
// Agregar seriales solo si la unidad lo permite y hay seriales ingresados
|
// Agregar seriales solo si la unidad lo permite y hay seriales ingresados
|
||||||
if (canUseSerials(item) && item.serial_numbers_list) {
|
if (canUseSerials(item) && item.serial_numbers_list) {
|
||||||
const serials = item.serial_numbers_list
|
const serials = item.serial_numbers_list
|
||||||
@ -367,6 +416,24 @@ watch(() => form.warehouse_id, (newWarehouseId, oldWarehouseId) => {
|
|||||||
:key="index"
|
:key="index"
|
||||||
class="p-3 border border-gray-200 dark:border-gray-700 rounded-lg bg-gray-50 dark:bg-gray-800/50"
|
class="p-3 border border-gray-200 dark:border-gray-700 rounded-lg bg-gray-50 dark:bg-gray-800/50"
|
||||||
>
|
>
|
||||||
|
<!-- Selector de unidad (si el producto tiene equivalencias activas) -->
|
||||||
|
<div v-if="item.equivalences?.length > 0" class="mb-3 flex items-center gap-3">
|
||||||
|
<label class="shrink-0 text-xs font-medium text-gray-600 dark:text-gray-400">Unidad:</label>
|
||||||
|
<select
|
||||||
|
v-model="item.selected_unit_id"
|
||||||
|
class="flex-1 px-2 py-1.5 text-sm border border-indigo-300 dark:border-indigo-600 rounded bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
|
||||||
|
>
|
||||||
|
<option :value="null">{{ item.base_unit?.name }} ({{ item.base_unit?.abbreviation }}) — unidad base</option>
|
||||||
|
<option
|
||||||
|
v-for="eq in item.equivalences"
|
||||||
|
:key="eq.unit_of_measure_id"
|
||||||
|
:value="eq.unit_of_measure_id"
|
||||||
|
>
|
||||||
|
{{ eq.unit_name }} — 1 {{ eq.unit_abbreviation }} = {{ parseFloat(eq.conversion_factor) }} {{ item.base_unit?.abbreviation }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="grid grid-cols-12 gap-3 items-start">
|
<div class="grid grid-cols-12 gap-3 items-start">
|
||||||
<!-- Producto -->
|
<!-- Producto -->
|
||||||
<div class="col-span-12 sm:col-span-5">
|
<div class="col-span-12 sm:col-span-5">
|
||||||
@ -447,17 +514,24 @@ watch(() => form.warehouse_id, (newWarehouseId, oldWarehouseId) => {
|
|||||||
<div class="col-span-5 sm:col-span-3">
|
<div class="col-span-5 sm:col-span-3">
|
||||||
<label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">
|
<label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">
|
||||||
Cantidad
|
Cantidad
|
||||||
|
<span v-if="getSelectedEquivalence(item)" class="text-indigo-600 dark:text-indigo-400">
|
||||||
|
({{ getSelectedEquivalence(item).unit_abbreviation }})
|
||||||
|
</span>
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
v-model="item.quantity"
|
v-model="item.quantity"
|
||||||
type="number"
|
type="number"
|
||||||
min="1"
|
min="1"
|
||||||
step="1"
|
:step="item.allows_decimals && !item.selected_unit_id ? '0.001' : '1'"
|
||||||
placeholder="0"
|
placeholder="0"
|
||||||
:disabled="item.track_serials && canUseSerials(item)"
|
:disabled="item.track_serials && canUseSerials(item)"
|
||||||
class="w-full px-2 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed disabled:bg-gray-100 dark:disabled:bg-gray-900"
|
class="w-full px-2 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed disabled:bg-gray-100 dark:disabled:bg-gray-900"
|
||||||
/>
|
/>
|
||||||
<p v-if="item.track_serials && canUseSerials(item)" class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
<!-- Conversión a unidad base -->
|
||||||
|
<p v-if="getSelectedEquivalence(item) && item.quantity > 0" class="mt-1 text-xs text-indigo-600 dark:text-indigo-400">
|
||||||
|
= {{ (item.quantity * parseFloat(getSelectedEquivalence(item).conversion_factor)).toLocaleString('es-MX') }} {{ item.base_unit?.abbreviation }}
|
||||||
|
</p>
|
||||||
|
<p v-else-if="item.track_serials && canUseSerials(item)" class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||||
Controlado por seriales
|
Controlado por seriales
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@ -465,7 +539,9 @@ watch(() => form.warehouse_id, (newWarehouseId, oldWarehouseId) => {
|
|||||||
<!-- Costo unitario -->
|
<!-- Costo unitario -->
|
||||||
<div class="col-span-5 sm:col-span-3">
|
<div class="col-span-5 sm:col-span-3">
|
||||||
<label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">
|
<label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">
|
||||||
Costo unit.
|
Costo
|
||||||
|
<span v-if="getSelectedEquivalence(item)">/ {{ getSelectedEquivalence(item).unit_abbreviation }}</span>
|
||||||
|
<span v-else>unit.</span>
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
v-model="item.unit_cost"
|
v-model="item.unit_cost"
|
||||||
|
|||||||
@ -18,6 +18,7 @@ import ClientModal from '@Components/POS/ClientModal.vue';
|
|||||||
import QRscan from '@Components/POS/QRscan.vue';
|
import QRscan from '@Components/POS/QRscan.vue';
|
||||||
import SerialSelector from '@Components/POS/SerialSelector.vue';
|
import SerialSelector from '@Components/POS/SerialSelector.vue';
|
||||||
import BundleSerialSelector from '@Components/POS/BundleSerialSelector.vue';
|
import BundleSerialSelector from '@Components/POS/BundleSerialSelector.vue';
|
||||||
|
import UnitEquivalenceSelector from '@Components/POS/UnitEquivalenceSelector.vue';
|
||||||
|
|
||||||
/** i18n */
|
/** i18n */
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
@ -46,6 +47,12 @@ const serialSelectorProduct = ref(null);
|
|||||||
const showBundleSerialSelector = ref(false);
|
const showBundleSerialSelector = ref(false);
|
||||||
const bundleSerialSelectorBundle = ref(null);
|
const bundleSerialSelectorBundle = ref(null);
|
||||||
|
|
||||||
|
// Estado para selector de equivalencias de unidad
|
||||||
|
const showUnitEquivalenceSelector = ref(false);
|
||||||
|
const unitEquivalenceSelectorProduct = ref(null);
|
||||||
|
const unitEquivalenceSelectorData = ref({ equivalences: [], baseUnit: null });
|
||||||
|
const equivalencesCache = ref({});
|
||||||
|
|
||||||
/** Buscador de productos */
|
/** Buscador de productos */
|
||||||
const searcher = useSearcher({
|
const searcher = useSearcher({
|
||||||
url: apiURL('inventario'),
|
url: apiURL('inventario'),
|
||||||
@ -142,6 +149,29 @@ const addBundleToCart = async (bundle) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const loadEquivalencesForProduct = async (productId) => {
|
||||||
|
if (equivalencesCache.value[productId]) {
|
||||||
|
return equivalencesCache.value[productId];
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const response = await fetch(apiURL(`inventario/${productId}/equivalencias`), {
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${sessionStorage.token}`,
|
||||||
|
'Accept': 'application/json'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const result = await response.json();
|
||||||
|
const data = {
|
||||||
|
equivalences: (result.data?.equivalences || []).filter(e => e.is_active),
|
||||||
|
baseUnit: result.data?.base_unit || null
|
||||||
|
};
|
||||||
|
equivalencesCache.value[productId] = data;
|
||||||
|
return data;
|
||||||
|
} catch {
|
||||||
|
return { equivalences: [], baseUnit: null };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const addToCart = async (product) => {
|
const addToCart = async (product) => {
|
||||||
try {
|
try {
|
||||||
const response = await serialService.getAvailableSerials(product.id);
|
const response = await serialService.getAvailableSerials(product.id);
|
||||||
@ -176,6 +206,27 @@ const addToCart = async (product) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Si el producto ya está en el carrito (sin seriales), solo incrementar sin abrir selectores
|
||||||
|
const existingItem = cart.items.find(i => i.item_key === 'p:' + product.id);
|
||||||
|
if (existingItem && !existingItem.track_serials) {
|
||||||
|
if (existingItem.quantity < product.stock) {
|
||||||
|
existingItem.quantity++;
|
||||||
|
window.Notify.success(`${product.name} agregado al carrito`);
|
||||||
|
} else {
|
||||||
|
window.Notify.warning('No hay suficiente stock disponible');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Producto nuevo: verificar si tiene equivalencias activas
|
||||||
|
const eqData = await loadEquivalencesForProduct(product.id);
|
||||||
|
if (eqData.equivalences.length > 0) {
|
||||||
|
unitEquivalenceSelectorProduct.value = product;
|
||||||
|
unitEquivalenceSelectorData.value = eqData;
|
||||||
|
showUnitEquivalenceSelector.value = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
cart.addProduct(product);
|
cart.addProduct(product);
|
||||||
window.Notify.success(`${product.name} agregado al carrito`);
|
window.Notify.success(`${product.name} agregado al carrito`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -185,6 +236,18 @@ const addToCart = async (product) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const closeUnitEquivalenceSelector = () => {
|
||||||
|
showUnitEquivalenceSelector.value = false;
|
||||||
|
unitEquivalenceSelectorProduct.value = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUnitEquivalenceConfirm = (unitConfig) => {
|
||||||
|
if (!unitEquivalenceSelectorProduct.value) return;
|
||||||
|
cart.addProduct(unitEquivalenceSelectorProduct.value, unitConfig);
|
||||||
|
window.Notify.success(`${unitEquivalenceSelectorProduct.value.name} agregado al carrito`);
|
||||||
|
closeUnitEquivalenceSelector();
|
||||||
|
};
|
||||||
|
|
||||||
const closeSerialSelector = () => {
|
const closeSerialSelector = () => {
|
||||||
showSerialSelector.value = false;
|
showSerialSelector.value = false;
|
||||||
serialSelectorProduct.value = null;
|
serialSelectorProduct.value = null;
|
||||||
@ -394,7 +457,7 @@ const handleConfirmSale = async (paymentData) => {
|
|||||||
}
|
}
|
||||||
return bundleItem;
|
return bundleItem;
|
||||||
}
|
}
|
||||||
return {
|
const productItem = {
|
||||||
type: 'product',
|
type: 'product',
|
||||||
inventory_id: item.inventory_id,
|
inventory_id: item.inventory_id,
|
||||||
product_name: item.product_name,
|
product_name: item.product_name,
|
||||||
@ -403,6 +466,10 @@ const handleConfirmSale = async (paymentData) => {
|
|||||||
subtotal: parseFloat((item.quantity * item.unit_price).toFixed(2)),
|
subtotal: parseFloat((item.quantity * item.unit_price).toFixed(2)),
|
||||||
serial_numbers: item.track_serials ? (item.serial_numbers || []) : undefined
|
serial_numbers: item.track_serials ? (item.serial_numbers || []) : undefined
|
||||||
};
|
};
|
||||||
|
if (item.unit_of_measure_id) {
|
||||||
|
productItem.unit_of_measure_id = item.unit_of_measure_id;
|
||||||
|
}
|
||||||
|
return productItem;
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -871,5 +938,16 @@ watch(activeTab, (newTab) => {
|
|||||||
@close="closeBundleSerialSelector"
|
@close="closeBundleSerialSelector"
|
||||||
@confirm="handleBundleSerialConfirm"
|
@confirm="handleBundleSerialConfirm"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<!-- Modal de Selección de Unidad de Medida -->
|
||||||
|
<UnitEquivalenceSelector
|
||||||
|
v-if="unitEquivalenceSelectorProduct"
|
||||||
|
:show="showUnitEquivalenceSelector"
|
||||||
|
:product="unitEquivalenceSelectorProduct"
|
||||||
|
:equivalences="unitEquivalenceSelectorData.equivalences"
|
||||||
|
:base-unit="unitEquivalenceSelectorData.baseUnit"
|
||||||
|
@close="closeUnitEquivalenceSelector"
|
||||||
|
@confirm="handleUnitEquivalenceConfirm"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@ -161,7 +161,6 @@ const closeModal = () => {
|
|||||||
v-model="form.notes"
|
v-model="form.notes"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Notas"
|
placeholder="Notas"
|
||||||
required
|
|
||||||
/>
|
/>
|
||||||
<FormError :message="form.errors?.notes" />
|
<FormError :message="form.errors?.notes" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
148
src/pages/POS/UnitMeasure/Create.vue
Normal file
148
src/pages/POS/UnitMeasure/Create.vue
Normal file
@ -0,0 +1,148 @@
|
|||||||
|
<script setup>
|
||||||
|
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 */
|
||||||
|
defineProps({
|
||||||
|
show: Boolean
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Formulario */
|
||||||
|
const form = useForm({
|
||||||
|
name: '',
|
||||||
|
abbreviation: '',
|
||||||
|
allows_decimals: false,
|
||||||
|
is_active: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Métodos */
|
||||||
|
const createUnit = () => {
|
||||||
|
form.post(apiURL('unidades-medida'), {
|
||||||
|
onSuccess: () => {
|
||||||
|
window.Notify.success('Unidad de medida creada exitosamente');
|
||||||
|
emit('created');
|
||||||
|
closeModal();
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
window.Notify.error('Error al crear la unidad de medida');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeModal = () => {
|
||||||
|
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">
|
||||||
|
Nueva Unidad de Medida
|
||||||
|
</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="createUnit" 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="Ej: Kilogramo"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<FormError :message="form.errors?.name" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Abreviación -->
|
||||||
|
<div class="col-span-2">
|
||||||
|
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
|
||||||
|
ABREVIACIÓN
|
||||||
|
</label>
|
||||||
|
<FormInput
|
||||||
|
v-model="form.abbreviation"
|
||||||
|
type="text"
|
||||||
|
placeholder="Ej: kg"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<FormError :message="form.errors?.abbreviation" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Permite decimales -->
|
||||||
|
<div class="col-span-2">
|
||||||
|
<label class="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input
|
||||||
|
v-model="form.allows_decimals"
|
||||||
|
type="checkbox"
|
||||||
|
class="w-4 h-4 text-indigo-600 bg-gray-100 border-gray-300 rounded focus:ring-indigo-500 dark:focus:ring-indigo-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600"
|
||||||
|
/>
|
||||||
|
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
Permite cantidades decimales
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400 ml-6">
|
||||||
|
Actívalo para unidades como kg, litros o metros. Desactívalo para piezas, cajas o unidades enteras.
|
||||||
|
</p>
|
||||||
|
<FormError :message="form.errors?.allows_decimals" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Activo -->
|
||||||
|
<div class="col-span-2">
|
||||||
|
<label class="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input
|
||||||
|
v-model="form.is_active"
|
||||||
|
type="checkbox"
|
||||||
|
class="w-4 h-4 text-indigo-600 bg-gray-100 border-gray-300 rounded focus:ring-indigo-500 dark:focus:ring-indigo-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600"
|
||||||
|
/>
|
||||||
|
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
Unidad activa
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<FormError :message="form.errors?.is_active" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 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>
|
||||||
160
src/pages/POS/UnitMeasure/Edit.vue
Normal file
160
src/pages/POS/UnitMeasure/Edit.vue
Normal file
@ -0,0 +1,160 @@
|
|||||||
|
<script setup>
|
||||||
|
import { watch } 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', 'updated']);
|
||||||
|
|
||||||
|
/** Propiedades */
|
||||||
|
const props = defineProps({
|
||||||
|
show: Boolean,
|
||||||
|
unit: Object
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Formulario */
|
||||||
|
const form = useForm({
|
||||||
|
name: '',
|
||||||
|
abbreviation: '',
|
||||||
|
allows_decimals: false,
|
||||||
|
is_active: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Observadores */
|
||||||
|
watch(() => props.unit, (newUnit) => {
|
||||||
|
if (newUnit) {
|
||||||
|
form.name = newUnit.name || '';
|
||||||
|
form.abbreviation = newUnit.abbreviation || '';
|
||||||
|
form.allows_decimals = !!newUnit.allows_decimals;
|
||||||
|
form.is_active = !!newUnit.is_active;
|
||||||
|
}
|
||||||
|
}, { immediate: true });
|
||||||
|
|
||||||
|
/** Métodos */
|
||||||
|
const updateUnit = () => {
|
||||||
|
form.put(apiURL(`unidades-medida/${props.unit.id}`), {
|
||||||
|
onSuccess: () => {
|
||||||
|
window.Notify.success('Unidad de medida actualizada exitosamente');
|
||||||
|
emit('updated');
|
||||||
|
closeModal();
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
window.Notify.error('Error al actualizar la unidad de medida');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeModal = () => {
|
||||||
|
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">
|
||||||
|
Editar Unidad de Medida
|
||||||
|
</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="updateUnit" 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="Ej: Kilogramo"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<FormError :message="form.errors?.name" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Abreviación -->
|
||||||
|
<div class="col-span-2">
|
||||||
|
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
|
||||||
|
ABREVIACIÓN
|
||||||
|
</label>
|
||||||
|
<FormInput
|
||||||
|
v-model="form.abbreviation"
|
||||||
|
type="text"
|
||||||
|
placeholder="Ej: kg"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<FormError :message="form.errors?.abbreviation" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Permite decimales -->
|
||||||
|
<div class="col-span-2">
|
||||||
|
<label class="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input
|
||||||
|
v-model="form.allows_decimals"
|
||||||
|
type="checkbox"
|
||||||
|
class="w-4 h-4 text-indigo-600 bg-gray-100 border-gray-300 rounded focus:ring-indigo-500 dark:focus:ring-indigo-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600"
|
||||||
|
/>
|
||||||
|
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
Permite cantidades decimales
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400 ml-6">
|
||||||
|
Actívalo para unidades como kg, litros o metros. Desactívalo para piezas, cajas o unidades enteras.
|
||||||
|
</p>
|
||||||
|
<FormError :message="form.errors?.allows_decimals" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Activo -->
|
||||||
|
<div class="col-span-2">
|
||||||
|
<label class="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input
|
||||||
|
v-model="form.is_active"
|
||||||
|
type="checkbox"
|
||||||
|
class="w-4 h-4 text-indigo-600 bg-gray-100 border-gray-300 rounded focus:ring-indigo-500 dark:focus:ring-indigo-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600"
|
||||||
|
/>
|
||||||
|
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
Unidad activa
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<FormError :message="form.errors?.is_active" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 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">Actualizando...</span>
|
||||||
|
<span v-else>Actualizar</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</template>
|
||||||
183
src/pages/POS/UnitMeasure/Index.vue
Normal file
183
src/pages/POS/UnitMeasure/Index.vue
Normal file
@ -0,0 +1,183 @@
|
|||||||
|
<script setup>
|
||||||
|
import { onMounted, ref } from 'vue';
|
||||||
|
import { api, useSearcher, apiURL } from '@Services/Api';
|
||||||
|
import { can } from './Module.js';
|
||||||
|
|
||||||
|
import SearcherHead from '@Holos/Searcher.vue';
|
||||||
|
import Table from '@Holos/Table.vue';
|
||||||
|
import GoogleIcon from '@Shared/GoogleIcon.vue';
|
||||||
|
import CreateModal from './Create.vue';
|
||||||
|
import EditModal from './Edit.vue';
|
||||||
|
|
||||||
|
/** Estado */
|
||||||
|
const units = ref([]);
|
||||||
|
const showCreateModal = ref(false);
|
||||||
|
const showEditModal = ref(false);
|
||||||
|
const editingUnit = ref(null);
|
||||||
|
|
||||||
|
/** Búsqueda / paginación */
|
||||||
|
const searcher = useSearcher({
|
||||||
|
url: apiURL('unidades-medida'),
|
||||||
|
onSuccess: (r) => {
|
||||||
|
units.value = r.units;
|
||||||
|
},
|
||||||
|
onError: () => { units.value = []; }
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Métodos */
|
||||||
|
const openCreateModal = () => { showCreateModal.value = true; };
|
||||||
|
const closeCreateModal = () => { showCreateModal.value = false; };
|
||||||
|
|
||||||
|
const openEditModal = (unit) => {
|
||||||
|
editingUnit.value = unit;
|
||||||
|
showEditModal.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeEditModal = () => {
|
||||||
|
showEditModal.value = false;
|
||||||
|
editingUnit.value = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onUnitSaved = () => { searcher.search(); };
|
||||||
|
|
||||||
|
const deleteUnit = (unit) => {
|
||||||
|
if (!confirm(`¿Eliminar la unidad "${unit.name}"? Esta acción no se puede deshacer.`)) return;
|
||||||
|
|
||||||
|
api.delete(apiURL(`unidades-medida/${unit.id}`), {
|
||||||
|
onSuccess: () => {
|
||||||
|
window.Notify.success('Unidad de medida eliminada');
|
||||||
|
searcher.search();
|
||||||
|
},
|
||||||
|
onFail: (data) => {
|
||||||
|
window.Notify.error(data.message || 'No se puede eliminar esta unidad porque está en uso');
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
window.Notify.error('Error de conexión al eliminar la unidad');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Ciclo de vida */
|
||||||
|
onMounted(() => { searcher.search(); });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<SearcherHead
|
||||||
|
title="Unidades de Medida"
|
||||||
|
placeholder="Buscar por nombre o abreviación..."
|
||||||
|
@search="(x) => searcher.search(x)"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
v-if="can('create')"
|
||||||
|
class="flex items-center gap-2 px-3 py-2 bg-indigo-600 hover:bg-indigo-700 text-white text-sm font-semibold rounded-lg transition-colors shadow-sm"
|
||||||
|
@click="openCreateModal"
|
||||||
|
>
|
||||||
|
<GoogleIcon name="add" class="text-xl" />
|
||||||
|
Nueva Unidad
|
||||||
|
</button>
|
||||||
|
</SearcherHead>
|
||||||
|
|
||||||
|
<div class="pt-2 w-full">
|
||||||
|
<Table
|
||||||
|
:items="units"
|
||||||
|
@send-pagination="(page) => searcher.pagination(page)"
|
||||||
|
>
|
||||||
|
<template #head>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">NOMBRE</th>
|
||||||
|
<th class="px-6 py-3 text-center text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">ABREVIACIÓN</th>
|
||||||
|
<th class="px-6 py-3 text-center text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">DECIMALES</th>
|
||||||
|
<th class="px-6 py-3 text-center text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">ESTADO</th>
|
||||||
|
<th class="px-6 py-3 text-center text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">ACCIONES</th>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #body="{ items }">
|
||||||
|
<tr
|
||||||
|
v-for="unit in items"
|
||||||
|
:key="unit.id"
|
||||||
|
class="hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
|
||||||
|
>
|
||||||
|
<td class="px-6 py-4">
|
||||||
|
<p class="text-sm font-semibold text-gray-900 dark:text-gray-100">{{ unit.name }}</p>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 text-center">
|
||||||
|
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-mono font-semibold bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200">
|
||||||
|
{{ unit.abbreviation }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 text-center">
|
||||||
|
<span
|
||||||
|
:class="[
|
||||||
|
'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium',
|
||||||
|
unit.allows_decimals
|
||||||
|
? 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200'
|
||||||
|
: 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300'
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
{{ unit.allows_decimals ? 'Sí' : 'No' }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 text-center">
|
||||||
|
<span
|
||||||
|
:class="[
|
||||||
|
'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium',
|
||||||
|
unit.is_active
|
||||||
|
? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200'
|
||||||
|
: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200'
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
{{ unit.is_active ? 'Activo' : 'Inactivo' }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-center">
|
||||||
|
<div class="flex items-center justify-center gap-2">
|
||||||
|
<button
|
||||||
|
v-if="can('edit')"
|
||||||
|
@click="openEditModal(unit)"
|
||||||
|
class="text-indigo-600 hover:text-indigo-900 dark:text-indigo-400 dark:hover:text-indigo-300 transition-colors"
|
||||||
|
title="Editar unidad"
|
||||||
|
>
|
||||||
|
<GoogleIcon name="edit" class="text-xl" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="can('destroy')"
|
||||||
|
@click="deleteUnit(unit)"
|
||||||
|
class="text-red-600 hover:text-red-900 dark:text-red-400 dark:hover:text-red-300 transition-colors"
|
||||||
|
title="Eliminar unidad"
|
||||||
|
>
|
||||||
|
<GoogleIcon name="delete" class="text-xl" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #empty>
|
||||||
|
<td colspan="5" class="table-cell text-center">
|
||||||
|
<div class="flex flex-col items-center justify-center py-8 text-gray-500">
|
||||||
|
<GoogleIcon name="straighten" class="text-6xl mb-2 opacity-50" />
|
||||||
|
<p class="font-semibold">No hay unidades de medida registradas</p>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</template>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Modal Crear -->
|
||||||
|
<CreateModal
|
||||||
|
v-if="can('create')"
|
||||||
|
:show="showCreateModal"
|
||||||
|
@close="closeCreateModal"
|
||||||
|
@created="onUnitSaved"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Modal Editar -->
|
||||||
|
<EditModal
|
||||||
|
v-if="can('edit')"
|
||||||
|
:show="showEditModal"
|
||||||
|
:unit="editingUnit"
|
||||||
|
@close="closeEditModal"
|
||||||
|
@updated="onUnitSaved"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
8
src/pages/POS/UnitMeasure/Module.js
Normal file
8
src/pages/POS/UnitMeasure/Module.js
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
import { hasPermission } from '@Plugins/RolePermission.js';
|
||||||
|
|
||||||
|
const can = (permission) => hasPermission(`units.${permission}`)
|
||||||
|
|
||||||
|
const viewTo = ({ name = '', params = {}, query = {} }) =>
|
||||||
|
({ name: `pos.unitMeasure.${name}`, params, query })
|
||||||
|
|
||||||
|
export { can, viewTo }
|
||||||
@ -129,6 +129,12 @@ const router = createRouter({
|
|||||||
name: 'pos.suppliers.index',
|
name: 'pos.suppliers.index',
|
||||||
beforeEnter: (to, from, next) => can(next, 'suppliers.index'),
|
beforeEnter: (to, from, next) => can(next, 'suppliers.index'),
|
||||||
component: () => import('@Pages/POS/Suppliers/Index.vue')
|
component: () => import('@Pages/POS/Suppliers/Index.vue')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'unit-measure',
|
||||||
|
name: 'pos.unitMeasure.index',
|
||||||
|
beforeEnter: (to, from, next) => can(next, 'units.index'),
|
||||||
|
component: () => import('@Pages/POS/UnitMeasure/Index.vue')
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
@ -39,7 +39,9 @@ const useCart = defineStore('cart', {
|
|||||||
|
|
||||||
actions: {
|
actions: {
|
||||||
// Agregar producto al carrito
|
// Agregar producto al carrito
|
||||||
addProduct(product, serialConfig = null) {
|
// config puede incluir: serialNumbers, selectionMode (para seriales)
|
||||||
|
// unit_of_measure_id, unit_price, unit_name (para equivalencias)
|
||||||
|
addProduct(product, config = null) {
|
||||||
const key = 'p:' + product.id;
|
const key = 'p:' + product.id;
|
||||||
const existingItem = this.items.find(item => item.item_key === key);
|
const existingItem = this.items.find(item => item.item_key === key);
|
||||||
|
|
||||||
@ -61,6 +63,11 @@ const useCart = defineStore('cart', {
|
|||||||
window.Notify.warning('No hay suficiente stock disponible');
|
window.Notify.warning('No hay suficiente stock disponible');
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
// Determinar precio: usar el de la equivalencia si se proporcionó, si no el base
|
||||||
|
const unitPrice = config?.unit_price !== undefined
|
||||||
|
? parseFloat(config.unit_price)
|
||||||
|
: parseFloat(product.price?.retail_price || 0);
|
||||||
|
|
||||||
// Agregar nuevo item
|
// Agregar nuevo item
|
||||||
this.items.push({
|
this.items.push({
|
||||||
item_key: key,
|
item_key: key,
|
||||||
@ -70,16 +77,19 @@ const useCart = defineStore('cart', {
|
|||||||
product_name: product.name,
|
product_name: product.name,
|
||||||
sku: product.sku,
|
sku: product.sku,
|
||||||
quantity: 1,
|
quantity: 1,
|
||||||
unit_price: parseFloat(product.price?.retail_price || 0),
|
unit_price: unitPrice,
|
||||||
tax_rate: parseFloat(product.price?.tax || 16),
|
tax_rate: parseFloat(product.price?.tax || 16),
|
||||||
max_stock: product.stock,
|
max_stock: product.stock,
|
||||||
// Campos para seriales
|
// Campos para seriales
|
||||||
track_serials: product.track_serials || false,
|
track_serials: product.track_serials || false,
|
||||||
serial_numbers: serialConfig?.serialNumbers || [],
|
serial_numbers: config?.serialNumbers || [],
|
||||||
serial_selection_mode: serialConfig?.selectionMode || null,
|
serial_selection_mode: config?.selectionMode || null,
|
||||||
// Campos para unidad de medida
|
// Campos para unidad de medida base
|
||||||
unit_of_measure: product.unit_of_measure || null,
|
unit_of_measure: product.unit_of_measure || null,
|
||||||
allows_decimals: product.unit_of_measure?.allows_decimals || false
|
allows_decimals: product.unit_of_measure?.allows_decimals || false,
|
||||||
|
// Campos para equivalencia de unidad seleccionada
|
||||||
|
unit_of_measure_id: config?.unit_of_measure_id || null,
|
||||||
|
unit_name: config?.unit_name || null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
25
src/utils/fiscalData.js
Normal file
25
src/utils/fiscalData.js
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
export const regimenFiscalOptions = [
|
||||||
|
{ value: '601', label: '601 - General de Ley Personas Morales' },
|
||||||
|
{ value: '603', label: '603 - Personas Morales con Fines no Lucrativos' },
|
||||||
|
{ value: '610', label: '610 - Residentes en el Extranjero sin Establecimiento Permanente en México' },
|
||||||
|
{ value: '620', label: '620 - Sociedades Cooperativas de Producción que optan por diferir sus ingresos' },
|
||||||
|
{ value: '622', label: '622 - Actividades Agrícolas, Ganaderas, Silvícolas y Pesqueras' },
|
||||||
|
{ value: '623', label: '623 - Opcional para Grupos de Sociedades' },
|
||||||
|
{ value: '624', label: '624 - Coordinados' },
|
||||||
|
{ value: '626', label: '626 - Régimen Simplificado de Confianza' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const usoCfdiOptions = [
|
||||||
|
{ value: 'G01', label: 'G01 - Adquisición de mercancías' },
|
||||||
|
{ value: 'G02', label: 'G02 - Devoluciones, descuentos o bonificaciones' },
|
||||||
|
{ value: 'G03', label: 'G03 - Gastos en general' },
|
||||||
|
{ value: 'I01', label: 'I01 - Construcciones' },
|
||||||
|
{ value: 'I02', label: 'I02 - Mobiliario y equipo de oficina por inversiones' },
|
||||||
|
{ value: 'I03', label: 'I03 - Equipo de transporte' },
|
||||||
|
{ value: 'I04', label: 'I04 - Equipo de computo y accesorios' },
|
||||||
|
{ value: 'I05', label: 'I05 - Dados, troqueles, moldes, matrices y herramental' },
|
||||||
|
{ value: 'I06', label: 'I06 - Comunicaciones telefónicas' },
|
||||||
|
{ value: 'I07', label: 'I07 - Comunicaciones satelitales' },
|
||||||
|
{ value: 'I08', label: 'I08 - Otra maquinaria y equipo' },
|
||||||
|
{ value: 'S01', label: 'S01 - Sin efectos fiscales' },
|
||||||
|
];
|
||||||
Loading…
x
Reference in New Issue
Block a user