feat: añadir módulo de devoluciones, vistas y lógica relacionada
This commit is contained in:
parent
83dd71f80f
commit
5c3df890e4
1
.gitignore
vendored
1
.gitignore
vendored
@ -25,3 +25,4 @@ notes.md
|
|||||||
*.njsproj
|
*.njsproj
|
||||||
*.sln
|
*.sln
|
||||||
*.sw?
|
*.sw?
|
||||||
|
CLAUDE.md
|
||||||
@ -11,7 +11,7 @@ const props = defineProps({
|
|||||||
});
|
});
|
||||||
|
|
||||||
/** Emits */
|
/** Emits */
|
||||||
const emit = defineEmits(['update-quantity', 'remove']);
|
const emit = defineEmits(['update-quantity', 'remove', 'select-serials']);
|
||||||
|
|
||||||
/** Computados */
|
/** Computados */
|
||||||
const formattedUnitPrice = computed(() => {
|
const formattedUnitPrice = computed(() => {
|
||||||
@ -29,7 +29,20 @@ const formattedSubtotal = computed(() => {
|
|||||||
}).format(subtotal);
|
}).format(subtotal);
|
||||||
});
|
});
|
||||||
|
|
||||||
const canIncrement = computed(() => props.item.quantity < props.item.max_stock);
|
const canIncrement = computed(() => {
|
||||||
|
// Si tiene seriales, no permitir incremento directo
|
||||||
|
if (props.item.track_serials) return false;
|
||||||
|
return props.item.quantity < props.item.max_stock;
|
||||||
|
});
|
||||||
|
|
||||||
|
const hasSerials = computed(() => props.item.track_serials);
|
||||||
|
const serialsSelected = computed(() => {
|
||||||
|
return props.item.serial_numbers && props.item.serial_numbers.length > 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
const needsSerialSelection = computed(() => {
|
||||||
|
return hasSerials.value && (!serialsSelected.value || props.item.serial_numbers.length !== props.item.quantity);
|
||||||
|
});
|
||||||
|
|
||||||
/** Métodos */
|
/** Métodos */
|
||||||
const increment = () => {
|
const increment = () => {
|
||||||
|
|||||||
@ -458,6 +458,7 @@ export default {
|
|||||||
cashRegister: 'Caja',
|
cashRegister: 'Caja',
|
||||||
point: 'Punto de Venta',
|
point: 'Punto de Venta',
|
||||||
sales: 'Ventas',
|
sales: 'Ventas',
|
||||||
|
returns: 'Devoluciones',
|
||||||
clients: 'Clientes',
|
clients: 'Clientes',
|
||||||
billingRequests: 'Solicitudes de Facturación'
|
billingRequests: 'Solicitudes de Facturación'
|
||||||
},
|
},
|
||||||
|
|||||||
@ -57,6 +57,11 @@ onMounted(() => {
|
|||||||
name="pos.sales"
|
name="pos.sales"
|
||||||
to="pos.sales.index"
|
to="pos.sales.index"
|
||||||
/>
|
/>
|
||||||
|
<Link
|
||||||
|
icon="Sync"
|
||||||
|
name="pos.returns"
|
||||||
|
to="pos.returns.index"
|
||||||
|
/>
|
||||||
<Link
|
<Link
|
||||||
icon="accessibility"
|
icon="accessibility"
|
||||||
name="pos.clients"
|
name="pos.clients"
|
||||||
|
|||||||
@ -30,7 +30,17 @@ const cashSales = computed(() => parseFloat(props.cashRegister?.cash_sales || 0)
|
|||||||
const cardSales = computed(() => parseFloat(props.cashRegister?.card_sales || 0));
|
const cardSales = computed(() => parseFloat(props.cashRegister?.card_sales || 0));
|
||||||
const transactionCount = computed(() => parseInt(props.cashRegister?.transaction_count || 0));
|
const transactionCount = computed(() => parseInt(props.cashRegister?.transaction_count || 0));
|
||||||
|
|
||||||
const expectedCash = computed(() => initialCash.value + cashSales.value);
|
// Devoluciones
|
||||||
|
const totalReturns = computed(() => parseFloat(props.cashRegister?.total_returns || 0));
|
||||||
|
const cashReturns = computed(() => parseFloat(props.cashRegister?.cash_returns || 0));
|
||||||
|
const cardReturns = computed(() => parseFloat(props.cashRegister?.card_returns || 0));
|
||||||
|
const hasReturns = computed(() => totalReturns.value > 0);
|
||||||
|
|
||||||
|
// Ventas netas (ventas - devoluciones)
|
||||||
|
const netSales = computed(() => totalSales.value - totalReturns.value);
|
||||||
|
const netCashSales = computed(() => cashSales.value - cashReturns.value);
|
||||||
|
|
||||||
|
const expectedCash = computed(() => initialCash.value + netCashSales.value);
|
||||||
const difference = computed(() => finalCash.value - expectedCash.value);
|
const difference = computed(() => finalCash.value - expectedCash.value);
|
||||||
const hasDifference = computed(() => Math.abs(difference.value) > 0.01);
|
const hasDifference = computed(() => Math.abs(difference.value) > 0.01);
|
||||||
|
|
||||||
@ -128,6 +138,44 @@ const handleClose = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Devoluciones (si existen) -->
|
||||||
|
<div v-if="hasReturns" class="bg-orange-50 dark:bg-orange-900/20 border border-orange-200 dark:border-orange-800 rounded-xl p-4">
|
||||||
|
<div class="flex items-center gap-2 mb-3">
|
||||||
|
<GoogleIcon name="assignment_return" class="text-orange-600 dark:text-orange-400" />
|
||||||
|
<h4 class="text-sm font-bold text-orange-900 dark:text-orange-100">
|
||||||
|
Devoluciones
|
||||||
|
</h4>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-3 gap-4">
|
||||||
|
<div>
|
||||||
|
<p class="text-xs text-orange-700 dark:text-orange-300 uppercase mb-1">Total Devoluciones</p>
|
||||||
|
<p class="text-lg font-bold text-orange-900 dark:text-orange-100">
|
||||||
|
-${{ (totalReturns || 0).toLocaleString('es-MX', { minimumFractionDigits: 2 }) }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-xs text-orange-700 dark:text-orange-300 uppercase mb-1">Efectivo</p>
|
||||||
|
<p class="text-lg font-bold text-orange-900 dark:text-orange-100">
|
||||||
|
-${{ (cashReturns || 0).toLocaleString('es-MX', { minimumFractionDigits: 2 }) }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-xs text-orange-700 dark:text-orange-300 uppercase mb-1">Tarjeta</p>
|
||||||
|
<p class="text-lg font-bold text-orange-900 dark:text-orange-100">
|
||||||
|
-${{ (cardReturns || 0).toLocaleString('es-MX', { minimumFractionDigits: 2 }) }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-3 pt-3 border-t border-orange-300 dark:border-orange-700">
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<span class="text-sm font-semibold text-orange-900 dark:text-orange-100">Ventas Netas:</span>
|
||||||
|
<span class="text-xl font-bold text-orange-900 dark:text-orange-100">
|
||||||
|
${{ (netSales || 0).toLocaleString('es-MX', { minimumFractionDigits: 2 }) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Cálculo de Efectivo -->
|
<!-- Cálculo de Efectivo -->
|
||||||
<div class="bg-gray-50 dark:bg-gray-800 rounded-xl p-5 space-y-4">
|
<div class="bg-gray-50 dark:bg-gray-800 rounded-xl p-5 space-y-4">
|
||||||
<h4 class="text-sm font-bold text-gray-900 dark:text-gray-100">
|
<h4 class="text-sm font-bold text-gray-900 dark:text-gray-100">
|
||||||
@ -159,11 +207,27 @@ const handleClose = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex justify-between items-center pt-2 border-t border-gray-300">
|
<div class="flex justify-between items-center pt-2 border-t border-gray-300">
|
||||||
<span class="text-sm text-gray-600 dark:text-gray-400">Ventas en Efectivo (Neto):</span>
|
<span class="text-sm text-gray-600 dark:text-gray-400">Ventas en Efectivo:</span>
|
||||||
<span class="text-base font-semibold text-green-600 dark:text-green-400">
|
<span class="text-base font-semibold text-green-600 dark:text-green-400">
|
||||||
= ${{ (cashSales || 0).toLocaleString('es-MX', { minimumFractionDigits: 2 }) }}
|
= ${{ (cashSales || 0).toLocaleString('es-MX', { minimumFractionDigits: 2 }) }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Devoluciones en Efectivo -->
|
||||||
|
<div v-if="hasReturns" class="flex justify-between items-center text-sm">
|
||||||
|
<span class="text-gray-600 dark:text-gray-400">Devoluciones en Efectivo:</span>
|
||||||
|
<span class="text-orange-600 dark:text-orange-400 font-semibold">
|
||||||
|
- ${{ (cashReturns || 0).toLocaleString('es-MX', { minimumFractionDigits: 2 }) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Ventas Netas en Efectivo -->
|
||||||
|
<div v-if="hasReturns" class="flex justify-between items-center pt-2 border-t border-gray-300">
|
||||||
|
<span class="text-sm font-bold text-gray-900 dark:text-gray-100">Ventas Netas en Efectivo:</span>
|
||||||
|
<span class="text-base font-bold text-green-600 dark:text-green-400">
|
||||||
|
= ${{ (netCashSales || 0).toLocaleString('es-MX', { minimumFractionDigits: 2 }) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="border-t-2 border-gray-300 dark:border-gray-600 pt-3">
|
<div class="border-t-2 border-gray-300 dark:border-gray-600 pt-3">
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { onMounted, ref, computed } from 'vue';
|
import { onMounted, onUnmounted, ref, computed } from 'vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import { useSearcher, apiURL } from '@Services/Api';
|
import { useSearcher, apiURL } from '@Services/Api';
|
||||||
import { page } from '@Services/Page';
|
import { page } from '@Services/Page';
|
||||||
@ -7,6 +7,7 @@ import { formatCurrency } from '@/utils/formatters';
|
|||||||
import useCart from '@Stores/cart';
|
import useCart from '@Stores/cart';
|
||||||
import salesService from '@Services/salesService';
|
import salesService from '@Services/salesService';
|
||||||
import ticketService from '@Services/ticketService';
|
import ticketService from '@Services/ticketService';
|
||||||
|
import serialService from '@Services/serialService';
|
||||||
|
|
||||||
import GoogleIcon from '@Shared/GoogleIcon.vue';
|
import GoogleIcon from '@Shared/GoogleIcon.vue';
|
||||||
import ProductCard from '@Components/POS/ProductCard.vue';
|
import ProductCard from '@Components/POS/ProductCard.vue';
|
||||||
@ -63,10 +64,15 @@ const filteredProducts = computed(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
/** Métodos */
|
/** Métodos */
|
||||||
const addToCart = (product) => {
|
const addToCart = async (product) => {
|
||||||
try {
|
try {
|
||||||
// Si el producto tiene seriales, mostrar selector
|
const response = await serialService.getAvailableSerials(product.id);
|
||||||
if (product.has_serials) {
|
|
||||||
|
const hasSerials = response.serials?.data?.length > 0;
|
||||||
|
|
||||||
|
if (hasSerials) {
|
||||||
|
// Agregar track_serials al producto para que el store lo reconozca
|
||||||
|
product.track_serials = true;
|
||||||
serialSelectorProduct.value = product;
|
serialSelectorProduct.value = product;
|
||||||
showSerialSelector.value = true;
|
showSerialSelector.value = true;
|
||||||
return;
|
return;
|
||||||
@ -75,7 +81,9 @@ const addToCart = (product) => {
|
|||||||
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) {
|
||||||
window.Notify.error(error.message || 'Error al agregar producto');
|
// Si el API falla, agregar sin seriales
|
||||||
|
cart.addProduct(product);
|
||||||
|
window.Notify.success(`${product.name} agregado al carrito`);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -185,10 +193,9 @@ const handleConfirmSale = async (paymentData) => {
|
|||||||
quantity: item.quantity,
|
quantity: item.quantity,
|
||||||
unit_price: item.unit_price,
|
unit_price: item.unit_price,
|
||||||
subtotal: parseFloat((item.quantity * item.unit_price).toFixed(2)),
|
subtotal: parseFloat((item.quantity * item.unit_price).toFixed(2)),
|
||||||
serial_numbers: item.has_serials ? (item.serial_numbers || []) : undefined
|
serial_numbers: item.track_serials ? (item.serial_numbers || []) : undefined
|
||||||
}))
|
}))
|
||||||
};
|
};
|
||||||
|
|
||||||
// Agregar cash_received si es pago en efectivo
|
// Agregar cash_received si es pago en efectivo
|
||||||
if (paymentData.paymentMethod === 'cash' && paymentData.cashReceived) {
|
if (paymentData.paymentMethod === 'cash' && paymentData.cashReceived) {
|
||||||
saleData.cash_received = parseFloat(paymentData.cashReceived.toFixed(2));
|
saleData.cash_received = parseFloat(paymentData.cashReceived.toFixed(2));
|
||||||
@ -265,9 +272,25 @@ const closeClientModal = () => {
|
|||||||
lastSaleData.value = null;
|
lastSaleData.value = null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Event listener para abrir selector de seriales */
|
||||||
|
const handleOpenSerialSelector = (event) => {
|
||||||
|
const productId = event.detail.productId;
|
||||||
|
const product = products.value.find(p => p.id === productId);
|
||||||
|
if (product) {
|
||||||
|
serialSelectorProduct.value = product;
|
||||||
|
showSerialSelector.value = true;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
/** Ciclo de vida */
|
/** Ciclo de vida */
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
searcher.search();
|
searcher.search();
|
||||||
|
// Escuchar evento personalizado para abrir selector de seriales
|
||||||
|
window.addEventListener('open-serial-selector', handleOpenSerialSelector);
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
window.removeEventListener('open-serial-selector', handleOpenSerialSelector);
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
496
src/pages/POS/Returns/CreateReturn.vue
Normal file
496
src/pages/POS/Returns/CreateReturn.vue
Normal file
@ -0,0 +1,496 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, computed, watch } from 'vue';
|
||||||
|
import { formatCurrency, formatDate } from '@/utils/formatters';
|
||||||
|
import { page } from '@Services/Page';
|
||||||
|
|
||||||
|
import returnsService from '@Services/returnsService';
|
||||||
|
import GoogleIcon from '@Shared/GoogleIcon.vue';
|
||||||
|
import Modal from '@Holos/Modal.vue';
|
||||||
|
import FormInput from '@Holos/Form/Input.vue';
|
||||||
|
import useCashRegister from '@/stores/cashRegister';
|
||||||
|
|
||||||
|
/** Props */
|
||||||
|
const props = defineProps({
|
||||||
|
show: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
sale: {
|
||||||
|
type: Object,
|
||||||
|
default: null
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Emits */
|
||||||
|
const emit = defineEmits(['close', 'created']);
|
||||||
|
|
||||||
|
/** Store */
|
||||||
|
const cashRegisterStore = useCashRegister();
|
||||||
|
|
||||||
|
/** Estado */
|
||||||
|
const step = ref(1); // 1: Buscar venta, 2: Seleccionar items
|
||||||
|
const loading = ref(false);
|
||||||
|
const saleId = ref('');
|
||||||
|
const saleData = ref(null);
|
||||||
|
const returnableItems = ref([]);
|
||||||
|
const selectedItems = ref([]);
|
||||||
|
const reason = ref('');
|
||||||
|
const reasonText = ref('');
|
||||||
|
const notes = ref('');
|
||||||
|
|
||||||
|
/** Opciones de motivo */
|
||||||
|
const reasonOptions = [
|
||||||
|
{ value: 'change_of_mind', label: 'Cambio de opinión' },
|
||||||
|
{ value: 'wrong_product', label: 'Producto incorrecto' },
|
||||||
|
{ value: 'damaged', label: 'Producto dañado' },
|
||||||
|
{ value: 'other', label: 'Otro' }
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Computados */
|
||||||
|
const hasSelectedItems = computed(() => {
|
||||||
|
return selectedItems.value.some(item => item.selected && item.quantity > 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalReturn = computed(() => {
|
||||||
|
return selectedItems.value
|
||||||
|
.filter(item => item.selected && item.quantity > 0)
|
||||||
|
.reduce((total, item) => {
|
||||||
|
return total + (parseFloat(item.unit_price) * item.quantity);
|
||||||
|
}, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
const canSubmit = computed(() => {
|
||||||
|
return hasSelectedItems.value && reason.value !== '';
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Watchers */
|
||||||
|
watch(() => props.show, (isShown) => {
|
||||||
|
if (isShown) {
|
||||||
|
resetForm();
|
||||||
|
if (props.sale) {
|
||||||
|
searchSale();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Métodos */
|
||||||
|
const resetForm = () => {
|
||||||
|
step.value = props.sale ? 2 : 1;
|
||||||
|
saleId.value = '';
|
||||||
|
saleData.value = null;
|
||||||
|
returnableItems.value = [];
|
||||||
|
selectedItems.value = [];
|
||||||
|
reason.value = '';
|
||||||
|
reasonText.value = '';
|
||||||
|
notes.value = '';
|
||||||
|
loading.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const searchSale = async () => {
|
||||||
|
const id = props.sale?.id || saleId.value;
|
||||||
|
|
||||||
|
if (!id || (!props.sale && typeof id === 'string' && !id.trim())) {
|
||||||
|
window.Notify.error('Ingresa el ID o folio de la venta');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
loading.value = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await returnsService.getReturnableItems(id);
|
||||||
|
|
||||||
|
// Guardar datos de la venta
|
||||||
|
saleData.value = response.sale || null;
|
||||||
|
|
||||||
|
// Obtener items devolvibles
|
||||||
|
const items = response.returnable_items || [];
|
||||||
|
|
||||||
|
if (!items || items.length === 0) {
|
||||||
|
window.Notify.warning('Esta venta no tiene items disponibles para devolución');
|
||||||
|
loading.value = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
returnableItems.value = items;
|
||||||
|
|
||||||
|
// Inicializar items seleccionables con estructura del API
|
||||||
|
selectedItems.value = items.map(item => ({
|
||||||
|
sale_detail_id: item.sale_detail_id,
|
||||||
|
inventory_id: item.inventory_id,
|
||||||
|
product_name: item.product_name,
|
||||||
|
unit_price: item.unit_price,
|
||||||
|
quantity_sold: item.quantity_sold,
|
||||||
|
quantity_already_returned: item.quantity_already_returned,
|
||||||
|
quantity_returnable: item.quantity_returnable,
|
||||||
|
available_serials: item.available_serials || [],
|
||||||
|
// Estado de selección
|
||||||
|
selected: false,
|
||||||
|
quantity: 0,
|
||||||
|
selectedSerials: []
|
||||||
|
}));
|
||||||
|
|
||||||
|
step.value = 2;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error al buscar venta:', error);
|
||||||
|
window.Notify.error('No se encontró la venta o no está disponible para devolución');
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleItem = (index) => {
|
||||||
|
const item = selectedItems.value[index];
|
||||||
|
item.selected = !item.selected;
|
||||||
|
|
||||||
|
if (item.selected) {
|
||||||
|
// Si tiene seriales, no asignar cantidad automáticamente
|
||||||
|
if (item.available_serials.length > 0) {
|
||||||
|
item.quantity = 0;
|
||||||
|
item.selectedSerials = [];
|
||||||
|
} else {
|
||||||
|
item.quantity = item.quantity_returnable;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
item.quantity = 0;
|
||||||
|
item.selectedSerials = [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateQuantity = (index, value) => {
|
||||||
|
const item = selectedItems.value[index];
|
||||||
|
const qty = parseInt(value) || 0;
|
||||||
|
item.quantity = Math.min(Math.max(0, qty), item.quantity_returnable);
|
||||||
|
item.selected = item.quantity > 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleSerial = (itemIndex, serial) => {
|
||||||
|
const item = selectedItems.value[itemIndex];
|
||||||
|
const serialIndex = item.selectedSerials.findIndex(s => s.serial_number === serial.serial_number);
|
||||||
|
|
||||||
|
if (serialIndex > -1) {
|
||||||
|
item.selectedSerials.splice(serialIndex, 1);
|
||||||
|
} else {
|
||||||
|
if (item.selectedSerials.length < item.quantity_returnable) {
|
||||||
|
item.selectedSerials.push(serial);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
item.quantity = item.selectedSerials.length;
|
||||||
|
item.selected = item.quantity > 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isSerialSelected = (itemIndex, serial) => {
|
||||||
|
const item = selectedItems.value[itemIndex];
|
||||||
|
return item.selectedSerials.some(s => s.serial_number === serial.serial_number);
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasSerials = (item) => {
|
||||||
|
return item.available_serials && item.available_serials.length > 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const goBack = () => {
|
||||||
|
if (props.sale) {
|
||||||
|
emit('close');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
step.value = 1;
|
||||||
|
selectedItems.value = [];
|
||||||
|
returnableItems.value = [];
|
||||||
|
saleData.value = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
resetForm();
|
||||||
|
emit('close');
|
||||||
|
};
|
||||||
|
|
||||||
|
const submitReturn = async () => {
|
||||||
|
if (!canSubmit.value) {
|
||||||
|
window.Notify.error('Selecciona al menos un item y proporciona un motivo');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
loading.value = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const items = selectedItems.value
|
||||||
|
.filter(item => item.selected && item.quantity > 0)
|
||||||
|
.map(item => ({
|
||||||
|
sale_detail_id: item.sale_detail_id,
|
||||||
|
quantity_returned: item.quantity,
|
||||||
|
serial_numbers: item.selectedSerials.map(s => s.serial_number)
|
||||||
|
}));
|
||||||
|
|
||||||
|
const returnData = {
|
||||||
|
sale_id: saleData.value?.id || parseInt(saleId.value),
|
||||||
|
user_id: page.user.id,
|
||||||
|
cash_register_id: cashRegisterStore.currentRegisterId || null,
|
||||||
|
refund_method: saleData.value?.payment_method || 'cash',
|
||||||
|
reason: reason.value,
|
||||||
|
notes: notes.value.trim() || null,
|
||||||
|
items: items
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await returnsService.createReturn(returnData);
|
||||||
|
window.Notify.success(response.message || 'Devolución procesada exitosamente');
|
||||||
|
emit('created');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error al crear devolución:', error);
|
||||||
|
window.Notify.error(error.message || 'Error al procesar la devolución');
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Modal :show="show" max-width="3xl" @close="handleClose">
|
||||||
|
<div class="p-6">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex items-center justify-between mb-6">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="w-12 h-12 bg-indigo-100 dark:bg-indigo-900/30 rounded-xl flex items-center justify-center">
|
||||||
|
<GoogleIcon name="sync" class="text-2xl text-indigo-600 dark:text-indigo-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 class="text-lg font-bold text-gray-900 dark:text-gray-100">
|
||||||
|
Nueva Devolución
|
||||||
|
</h3>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{{ step === 1 ? 'Buscar venta' : 'Seleccionar productos a devolver' }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
@click="handleClose"
|
||||||
|
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
|
||||||
|
>
|
||||||
|
<GoogleIcon name="close" class="text-2xl" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Indicador de pasos -->
|
||||||
|
<div v-if="!props.sale" class="flex items-center gap-2 mb-6">
|
||||||
|
<div
|
||||||
|
class="flex items-center gap-2 px-3 py-1.5 rounded-full text-sm font-medium"
|
||||||
|
:class="step >= 1 ? 'bg-indigo-100 text-indigo-700 dark:bg-indigo-900/30 dark:text-indigo-400' : 'bg-gray-100 text-gray-500 dark:bg-gray-800 dark:text-gray-400'"
|
||||||
|
>
|
||||||
|
<span class="w-5 h-5 rounded-full bg-indigo-600 text-white text-xs flex items-center justify-center">1</span>
|
||||||
|
Buscar Venta
|
||||||
|
</div>
|
||||||
|
<GoogleIcon name="chevron_right" class="text-gray-400" />
|
||||||
|
<div
|
||||||
|
class="flex items-center gap-2 px-3 py-1.5 rounded-full text-sm font-medium"
|
||||||
|
:class="step >= 2 ? 'bg-indigo-100 text-indigo-700 dark:bg-indigo-900/30 dark:text-indigo-400' : 'bg-gray-100 text-gray-500 dark:bg-gray-800 dark:text-gray-400'"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="w-5 h-5 rounded-full text-xs flex items-center justify-center"
|
||||||
|
:class="step >= 2 ? 'bg-indigo-600 text-white' : 'bg-gray-300 dark:bg-gray-600 text-gray-600 dark:text-gray-300'"
|
||||||
|
>2</span>
|
||||||
|
Seleccionar Items
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Seleccionar Items -->
|
||||||
|
<div v-if="step === 2" class="space-y-4">
|
||||||
|
|
||||||
|
<!-- Info de la venta -->
|
||||||
|
<div class="bg-gray-50 dark:bg-gray-800 rounded-lg p-4 mb-4">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400">Venta seleccionada:</p>
|
||||||
|
<p class="text-lg font-semibold text-indigo-600 dark:text-indigo-400 font-mono">
|
||||||
|
{{ saleData?.invoice_number || `#${saleId}` }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="text-right">
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400">Total de venta:</p>
|
||||||
|
<p class="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||||
|
{{ formatCurrency(saleData?.total) }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Lista de items -->
|
||||||
|
<div class="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden max-h-64 overflow-y-auto">
|
||||||
|
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
|
<thead class="bg-gray-50 dark:bg-gray-800 sticky top-0">
|
||||||
|
<tr>
|
||||||
|
<th class="px-4 py-3 text-left text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase w-10">
|
||||||
|
<span class="sr-only">Seleccionar</span>
|
||||||
|
</th>
|
||||||
|
<th class="px-4 py-3 text-left text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase">
|
||||||
|
Producto
|
||||||
|
</th>
|
||||||
|
<th class="px-4 py-3 text-center text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase w-32">
|
||||||
|
Cantidad
|
||||||
|
</th>
|
||||||
|
<th class="px-4 py-3 text-right text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase w-28">
|
||||||
|
Precio
|
||||||
|
</th>
|
||||||
|
<th class="px-4 py-3 text-right text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase w-28">
|
||||||
|
Subtotal
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="bg-white dark:bg-gray-900 divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
|
<template v-for="(item, index) in selectedItems" :key="item.sale_detail_id">
|
||||||
|
<tr
|
||||||
|
class="hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
|
||||||
|
:class="{ 'bg-indigo-50 dark:bg-indigo-900/20': item.selected }"
|
||||||
|
>
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
:checked="item.selected"
|
||||||
|
@change="toggleItem(index)"
|
||||||
|
class="w-4 h-4 text-indigo-600 border-gray-300 rounded focus:ring-indigo-500"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
<p class="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||||
|
{{ item.product_name }}
|
||||||
|
<span></span>
|
||||||
|
</p>
|
||||||
|
<div class="flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
<span>Vendido: {{ item.quantity_sold }}</span>
|
||||||
|
<span v-if="item.quantity_already_returned > 0" class="text-orange-600 dark:text-orange-400">
|
||||||
|
• Ya devuelto: {{ item.quantity_already_returned }}
|
||||||
|
</span>
|
||||||
|
<span class="text-green-600 dark:text-green-400">
|
||||||
|
• Disponible: {{ item.quantity_returnable }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-center">
|
||||||
|
<input
|
||||||
|
v-if="!hasSerials(item)"
|
||||||
|
type="number"
|
||||||
|
:value="item.quantity"
|
||||||
|
:max="item.quantity_returnable"
|
||||||
|
:min="0"
|
||||||
|
@input="updateQuantity(index, $event.target.value)"
|
||||||
|
class="w-20 px-2 py-1 text-center text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-indigo-500 focus:border-indigo-500"
|
||||||
|
/>
|
||||||
|
<span v-else class="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||||
|
{{ item.quantity }} / {{ item.quantity_returnable }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-right">
|
||||||
|
<span class="text-sm text-gray-700 dark:text-gray-300">
|
||||||
|
{{ formatCurrency(item.unit_price) }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-right">
|
||||||
|
<span class="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
||||||
|
{{ formatCurrency(parseFloat(item.unit_price) * item.quantity) }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<!-- Fila de seriales si el producto tiene -->
|
||||||
|
<tr v-if="hasSerials(item) && item.selected">
|
||||||
|
<td colspan="5" class="px-4 py-3 bg-gray-50 dark:bg-gray-800/50">
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 mb-2">
|
||||||
|
Selecciona los seriales a devolver:
|
||||||
|
</p>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<button
|
||||||
|
v-for="serial in item.available_serials"
|
||||||
|
:key="serial.serial_number"
|
||||||
|
@click="toggleSerial(index, serial)"
|
||||||
|
class="px-3 py-1 text-xs font-mono rounded-lg border transition-colors"
|
||||||
|
:class="isSerialSelected(index, serial)
|
||||||
|
? 'bg-indigo-100 border-indigo-300 text-indigo-700 dark:bg-indigo-900/30 dark:border-indigo-700 dark:text-indigo-400'
|
||||||
|
: 'bg-white border-gray-300 text-gray-700 hover:border-indigo-300 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-300'"
|
||||||
|
>
|
||||||
|
{{ serial.serial_number }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</template>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Motivo y Método de Reembolso -->
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-1 gap-4">
|
||||||
|
<!-- Motivo de devolución -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
|
Motivo de devolución <span class="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
v-model="reason"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-indigo-500 focus:border-indigo-500"
|
||||||
|
>
|
||||||
|
<option value="">Selecciona un motivo</option>
|
||||||
|
<option v-for="opt in reasonOptions" :key="opt.value" :value="opt.value">
|
||||||
|
{{ opt.label }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Descripción adicional del motivo -->
|
||||||
|
<div v-if="reason === 'other'">
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
|
Describe el motivo
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
v-model="reasonText"
|
||||||
|
rows="2"
|
||||||
|
placeholder="Describe el motivo de la devolución..."
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder-gray-400 focus:ring-indigo-500 focus:border-indigo-500 resize-none"
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Notas adicionales -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
|
Notas adicionales (opcional)
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
v-model="notes"
|
||||||
|
rows="2"
|
||||||
|
placeholder="Información adicional sobre la devolución..."
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder-gray-400 focus:ring-indigo-500 focus:border-indigo-500 resize-none"
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Total a devolver -->
|
||||||
|
<div class="flex justify-end">
|
||||||
|
<div class="bg-indigo-50 dark:bg-indigo-900/20 border border-indigo-200 dark:border-indigo-800 rounded-lg px-6 py-4">
|
||||||
|
<p class="text-xs text-indigo-600 dark:text-indigo-400 uppercase mb-1">Total a Devolver</p>
|
||||||
|
<p class="text-2xl font-bold text-indigo-700 dark:text-indigo-300">
|
||||||
|
{{ formatCurrency(totalReturn) }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<div class="flex justify-end gap-3 mt-6 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="px-4 py-2 bg-gray-100 hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-300 text-sm font-semibold rounded-lg transition-colors"
|
||||||
|
@click="handleClose"
|
||||||
|
>
|
||||||
|
Cancelar
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="step === 2"
|
||||||
|
type="button"
|
||||||
|
:disabled="!canSubmit || loading"
|
||||||
|
class="px-4 py-2 bg-indigo-600 hover:bg-indigo-700 disabled:bg-indigo-400 disabled:cursor-not-allowed text-white text-sm font-semibold rounded-lg transition-colors flex items-center gap-2"
|
||||||
|
@click="submitReturn"
|
||||||
|
>
|
||||||
|
<GoogleIcon v-if="loading" name="sync" class="animate-spin" />
|
||||||
|
<GoogleIcon v-else name="check" />
|
||||||
|
Procesar Devolución
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</template>
|
||||||
211
src/pages/POS/Returns/Index.vue
Normal file
211
src/pages/POS/Returns/Index.vue
Normal file
@ -0,0 +1,211 @@
|
|||||||
|
<script setup>
|
||||||
|
import { onMounted, ref } from 'vue';
|
||||||
|
import { useSearcher, apiURL } from '@Services/Api';
|
||||||
|
import returnsService from '@Services/returnsService';
|
||||||
|
import { formatCurrency, formatDate } from '@/utils/formatters';
|
||||||
|
|
||||||
|
import SearcherHead from '@Holos/Searcher.vue';
|
||||||
|
import Table from '@Holos/Table.vue';
|
||||||
|
import GoogleIcon from '@Shared/GoogleIcon.vue';
|
||||||
|
import ReturnDetailModal from './ReturnDetail.vue';
|
||||||
|
import CreateReturnModal from './CreateReturn.vue';
|
||||||
|
|
||||||
|
/** Estado */
|
||||||
|
const models = ref([]);
|
||||||
|
const showDetailModal = ref(false);
|
||||||
|
const showCreateModal = ref(false);
|
||||||
|
const selectedReturn = ref(null);
|
||||||
|
|
||||||
|
/** Buscador de devoluciones */
|
||||||
|
const searcher = useSearcher({
|
||||||
|
url: apiURL('returns'),
|
||||||
|
onSuccess: (r) => {
|
||||||
|
models.value = r.returns || r.models || { data: [], total: 0 };
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
console.error('❌ ERROR al cargar devoluciones:', error);
|
||||||
|
models.value = { data: [], total: 0 };
|
||||||
|
window.Notify.error('Error al cargar devoluciones');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Métodos */
|
||||||
|
const openDetailModal = async (returnItem) => {
|
||||||
|
try {
|
||||||
|
const details = await returnsService.getReturnDetails(returnItem.id);
|
||||||
|
selectedReturn.value = details;
|
||||||
|
showDetailModal.value = true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error al cargar detalles:', error);
|
||||||
|
window.Notify.error('Error al cargar los detalles de la devolución');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeDetailModal = () => {
|
||||||
|
showDetailModal.value = false;
|
||||||
|
selectedReturn.value = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onReturnCancelled = () => {
|
||||||
|
closeDetailModal();
|
||||||
|
searcher.search();
|
||||||
|
};
|
||||||
|
|
||||||
|
const openCreateModal = () => {
|
||||||
|
showCreateModal.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeCreateModal = () => {
|
||||||
|
showCreateModal.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onReturnCreated = () => {
|
||||||
|
closeCreateModal();
|
||||||
|
searcher.search();
|
||||||
|
window.Notify.success('Devolución creada exitosamente');
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Helpers de método de reembolso */
|
||||||
|
const getRefundMethodLabel = (method) => {
|
||||||
|
const labels = {
|
||||||
|
cash: 'Efectivo',
|
||||||
|
card: 'Tarjeta',
|
||||||
|
store_credit: 'Crédito tienda'
|
||||||
|
};
|
||||||
|
return labels[method] || method || '-';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getRefundMethodIcon = (method) => {
|
||||||
|
const icons = {
|
||||||
|
cash: 'payments',
|
||||||
|
card: 'credit_card',
|
||||||
|
store_credit: 'account_balance_wallet'
|
||||||
|
};
|
||||||
|
return icons[method] || 'payment';
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Ciclo de vida */
|
||||||
|
onMounted(() => {
|
||||||
|
searcher.search();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<SearcherHead
|
||||||
|
title="Devoluciones"
|
||||||
|
placeholder="Buscar por folio de venta o usuario..."
|
||||||
|
@search="(x) => searcher.search(x)"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
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 Devolución
|
||||||
|
</button>
|
||||||
|
</SearcherHead>
|
||||||
|
<div class="pt-2 w-full">
|
||||||
|
<Table
|
||||||
|
:items="models"
|
||||||
|
:processing="searcher.processing"
|
||||||
|
@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">FOLIO</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">VENTA ORIGINAL</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">FECHA</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">USUARIO</th>
|
||||||
|
<th class="px-6 py-3 text-center text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">REEMBOLSO</th>
|
||||||
|
<th class="px-6 py-3 text-right text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">TOTAL</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="model in items"
|
||||||
|
:key="model.id"
|
||||||
|
class="hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
|
||||||
|
>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="text-sm font-mono font-semibold text-gray-900 dark:text-gray-100">
|
||||||
|
{{ model.return_number }}
|
||||||
|
</span>
|
||||||
|
<span v-if="model.deleted_at"
|
||||||
|
class="px-2 py-0.5 text-xs font-semibold rounded-full bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300">
|
||||||
|
Cancelada
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap">
|
||||||
|
<span class="text-sm font-mono text-indigo-600 dark:text-indigo-400">
|
||||||
|
{{ model.sale?.invoice_number || '-' }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap">
|
||||||
|
<span class="text-sm text-gray-700 dark:text-gray-300">
|
||||||
|
{{ formatDate(model.created_at) }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap">
|
||||||
|
<span class="text-sm text-gray-700 dark:text-gray-300">
|
||||||
|
{{ model.user?.name || '-' }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-center">
|
||||||
|
<span class="inline-flex items-center gap-1 px-2.5 py-1 text-xs font-medium rounded-full bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300">
|
||||||
|
<GoogleIcon :name="getRefundMethodIcon(model.refund_method)" class="text-sm" />
|
||||||
|
{{ getRefundMethodLabel(model.refund_method) }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-right">
|
||||||
|
<span class="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
||||||
|
{{ formatCurrency(model.total) }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-center">
|
||||||
|
<button
|
||||||
|
@click="openDetailModal(model)"
|
||||||
|
class="text-indigo-600 hover:text-indigo-900 dark:text-indigo-400 dark:hover:text-indigo-300 transition-colors"
|
||||||
|
title="Ver detalles"
|
||||||
|
>
|
||||||
|
<GoogleIcon name="visibility" class="text-xl" />
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</template>
|
||||||
|
<template #empty>
|
||||||
|
<td colspan="7" class="table-cell text-center">
|
||||||
|
<div class="flex flex-col items-center justify-center py-8 text-gray-500">
|
||||||
|
<GoogleIcon
|
||||||
|
name="sync"
|
||||||
|
class="text-6xl mb-2 opacity-50"
|
||||||
|
/>
|
||||||
|
<p class="font-semibold">
|
||||||
|
No hay devoluciones registradas
|
||||||
|
</p>
|
||||||
|
<p class="text-sm mt-1">
|
||||||
|
Las devoluciones aparecerán aquí
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</template>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Modal de Detalle -->
|
||||||
|
<ReturnDetailModal
|
||||||
|
:show="showDetailModal"
|
||||||
|
:return-data="selectedReturn"
|
||||||
|
@close="closeDetailModal"
|
||||||
|
@cancelled="onReturnCancelled"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Modal de Crear Devolución -->
|
||||||
|
<CreateReturnModal
|
||||||
|
:show="showCreateModal"
|
||||||
|
@close="closeCreateModal"
|
||||||
|
@created="onReturnCreated"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
316
src/pages/POS/Returns/ReturnDetail.vue
Normal file
316
src/pages/POS/Returns/ReturnDetail.vue
Normal file
@ -0,0 +1,316 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { formatCurrency, formatDate } from '@/utils/formatters';
|
||||||
|
import GoogleIcon from '@Shared/GoogleIcon.vue';
|
||||||
|
import Modal from '@Holos/Modal.vue';
|
||||||
|
import returnsService from '@Services/returnsService';
|
||||||
|
|
||||||
|
/** Props */
|
||||||
|
const props = defineProps({
|
||||||
|
show: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
returnData: {
|
||||||
|
type: Object,
|
||||||
|
default: null
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Emits */
|
||||||
|
const emit = defineEmits(['close', 'cancelled']);
|
||||||
|
|
||||||
|
/** Computados */
|
||||||
|
const returnItems = computed(() => {
|
||||||
|
return props.returnData?.details || [];
|
||||||
|
});
|
||||||
|
|
||||||
|
const hasItems = computed(() => {
|
||||||
|
return returnItems.value.length > 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
const formattedTotal = computed(() => {
|
||||||
|
return formatCurrency(props.returnData?.total || 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
const formattedSubtotal = computed(() => {
|
||||||
|
return formatCurrency(props.returnData?.subtotal || 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
const formattedTax = computed(() => {
|
||||||
|
return formatCurrency(props.returnData?.tax || 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
const formattedDate = computed(() => {
|
||||||
|
if (!props.returnData?.created_at) return '-';
|
||||||
|
return formatDate(props.returnData.created_at);
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Helpers de estado */
|
||||||
|
const getRefundMethodBadge = (method) => {
|
||||||
|
const badges = {
|
||||||
|
cash: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300',
|
||||||
|
card: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300',
|
||||||
|
store_credit: 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-300'
|
||||||
|
};
|
||||||
|
return badges[method] || 'bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-300';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getRefundMethodLabel = (method) => {
|
||||||
|
const labels = {
|
||||||
|
cash: 'Efectivo',
|
||||||
|
card: 'Tarjeta',
|
||||||
|
store_credit: 'Crédito tienda'
|
||||||
|
};
|
||||||
|
return labels[method] || method || '-';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getRefundMethodIcon = (method) => {
|
||||||
|
const icons = {
|
||||||
|
cash: 'payments',
|
||||||
|
card: 'credit_card',
|
||||||
|
store_credit: 'account_balance_wallet'
|
||||||
|
};
|
||||||
|
return icons[method] || 'payment';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getReasonLabel = (reason) => {
|
||||||
|
const labels = {
|
||||||
|
defective: 'Producto defectuoso',
|
||||||
|
wrong_item: 'Producto incorrecto',
|
||||||
|
not_needed: 'Ya no lo necesita',
|
||||||
|
other: 'Otro'
|
||||||
|
};
|
||||||
|
return labels[reason] || reason || '-';
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Métodos */
|
||||||
|
const handleClose = () => {
|
||||||
|
emit('close');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancelReturn = async () => {
|
||||||
|
if (!confirm('¿Cancelar esta devolución? Los productos volverán a estado vendido y el stock se revertirá.')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await returnsService.cancelReturn(props.returnData.id);
|
||||||
|
window.Notify.success(response.message || 'Devolución cancelada exitosamente');
|
||||||
|
emit('cancelled');
|
||||||
|
emit('close');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error al cancelar devolución:', error);
|
||||||
|
window.Notify.error('Error al cancelar la devolución');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Modal :show="show" max-width="2xl" @close="handleClose">
|
||||||
|
<div class="p-6">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex items-center justify-between mb-6">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="w-12 h-12 bg-indigo-100 dark:bg-indigo-900/30 rounded-xl flex items-center justify-center">
|
||||||
|
<GoogleIcon name="sync" class="text-2xl text-indigo-600 dark:text-indigo-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 class="text-lg font-bold text-gray-900 dark:text-gray-100">
|
||||||
|
{{ returnData?.return_number || `Devolución #${returnData?.id}` }}
|
||||||
|
</h3>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{{ formattedDate }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
@click="handleClose"
|
||||||
|
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
|
||||||
|
>
|
||||||
|
<GoogleIcon name="close" class="text-2xl" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Información General -->
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
|
||||||
|
<!-- Venta Original -->
|
||||||
|
<div class="bg-gray-50 dark:bg-gray-800 rounded-lg p-4">
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 uppercase mb-1">Venta Original</p>
|
||||||
|
<p class="text-lg font-semibold text-indigo-600 dark:text-indigo-400 font-mono">
|
||||||
|
{{ returnData?.sale?.invoice_number || `#${returnData?.sale_id}` }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="returnData?.deleted_at" class="bg-gray-50 dark:bg-gray-800 rounded-lg p-4">
|
||||||
|
<span class="inline-flex items-center gap-1 px-2.5 py-1 text-xs font-semibold rounded-full bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300">
|
||||||
|
<GoogleIcon name="cancel" class="text-sm" />
|
||||||
|
Cancelada
|
||||||
|
</span>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-2">
|
||||||
|
{{ formatDate(returnData.deleted_at) }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Usuario -->
|
||||||
|
<div class="bg-gray-50 dark:bg-gray-800 rounded-lg p-4">
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 uppercase mb-1">Procesado por</p>
|
||||||
|
<p class="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||||
|
{{ returnData?.user?.name || '-' }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Método de Reembolso -->
|
||||||
|
<div class="bg-gray-50 dark:bg-gray-800 rounded-lg p-4">
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 uppercase mb-1">Método de Reembolso</p>
|
||||||
|
<span
|
||||||
|
class="inline-flex items-center gap-1 px-2.5 py-1 text-xs font-semibold rounded-full"
|
||||||
|
:class="getRefundMethodBadge(returnData?.refund_method)"
|
||||||
|
>
|
||||||
|
<GoogleIcon :name="getRefundMethodIcon(returnData?.refund_method)" class="text-sm" />
|
||||||
|
{{ getRefundMethodLabel(returnData?.refund_method) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Motivo de devolución -->
|
||||||
|
<div v-if="returnData?.reason || returnData?.reason_text" class="mb-6">
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 uppercase mb-2">Motivo de Devolución</p>
|
||||||
|
<div class="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-4">
|
||||||
|
<p class="text-sm font-semibold text-yellow-800 dark:text-yellow-300 mb-1">
|
||||||
|
{{ getReasonLabel(returnData?.reason) }}
|
||||||
|
</p>
|
||||||
|
<p v-if="returnData?.reason_text" class="text-sm text-yellow-700 dark:text-yellow-400">
|
||||||
|
{{ returnData.reason_text }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Notas adicionales -->
|
||||||
|
<div v-if="returnData?.notes" class="mb-6">
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 uppercase mb-2">Notas</p>
|
||||||
|
<div class="bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
||||||
|
<p class="text-sm text-gray-700 dark:text-gray-300">
|
||||||
|
{{ returnData.notes }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Items Devueltos -->
|
||||||
|
<div class="mb-6">
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 uppercase mb-3">
|
||||||
|
Productos Devueltos ({{ returnItems.length }})
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div v-if="hasItems" class="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
|
||||||
|
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
|
<thead class="bg-gray-50 dark:bg-gray-800">
|
||||||
|
<tr>
|
||||||
|
<th class="px-4 py-3 text-left text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase">
|
||||||
|
Producto
|
||||||
|
</th>
|
||||||
|
<th class="px-4 py-3 text-center text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase">
|
||||||
|
Cantidad
|
||||||
|
</th>
|
||||||
|
<th class="px-4 py-3 text-right text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase">
|
||||||
|
Precio Unit.
|
||||||
|
</th>
|
||||||
|
<th class="px-4 py-3 text-right text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase">
|
||||||
|
Subtotal
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="bg-white dark:bg-gray-900 divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
|
<tr v-for="item in returnItems" :key="item.id">
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||||
|
{{ item.product_name || item.inventory?.name || 'Producto' }}
|
||||||
|
</p>
|
||||||
|
<p v-if="item.inventory?.sku" class="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
SKU: {{ item.inventory.sku }}
|
||||||
|
</p>
|
||||||
|
<!-- Seriales devueltos -->
|
||||||
|
<div v-if="item.serials && item.serials.length > 0" class="mt-1">
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400">Seriales:</p>
|
||||||
|
<div class="flex flex-wrap gap-1 mt-1">
|
||||||
|
<span
|
||||||
|
v-for="serial in item.serials"
|
||||||
|
:key="serial.id || serial.serial_number"
|
||||||
|
class="inline-flex px-2 py-0.5 text-xs bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded font-mono"
|
||||||
|
>
|
||||||
|
{{ serial.serial_number }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-center">
|
||||||
|
<span class="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||||
|
{{ item.quantity_returned || item.quantity }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-right">
|
||||||
|
<span class="text-sm text-gray-700 dark:text-gray-300">
|
||||||
|
{{ formatCurrency(item.unit_price) }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-right">
|
||||||
|
<span class="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
||||||
|
{{ formatCurrency(item.subtotal) }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="text-center py-8 text-gray-500 dark:text-gray-400">
|
||||||
|
<GoogleIcon name="inventory_2" class="text-4xl mb-2 opacity-50" />
|
||||||
|
<p>No hay items en esta devolución</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Totales -->
|
||||||
|
<div class="border-t border-gray-200 dark:border-gray-700 pt-4">
|
||||||
|
<div class="flex justify-end">
|
||||||
|
<div class="w-64 space-y-2">
|
||||||
|
<div class="flex justify-between text-sm">
|
||||||
|
<span class="text-gray-600 dark:text-gray-400">Subtotal:</span>
|
||||||
|
<span class="text-gray-900 dark:text-gray-100">{{ formattedSubtotal }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between text-sm">
|
||||||
|
<span class="text-gray-600 dark:text-gray-400">IVA:</span>
|
||||||
|
<span class="text-gray-900 dark:text-gray-100">{{ formattedTax }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between pt-2 border-t border-gray-200 dark:border-gray-700">
|
||||||
|
<span class="text-base font-semibold text-gray-900 dark:text-gray-100">Total Devuelto:</span>
|
||||||
|
<span class="text-xl font-bold text-indigo-600 dark:text-indigo-400">{{ formattedTotal }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<div class="flex justify-between items-center mt-6">
|
||||||
|
<button
|
||||||
|
v-if="!returnData?.deleted_at"
|
||||||
|
type="button"
|
||||||
|
class="flex items-center gap-2 px-4 py-2 bg-red-600 hover:bg-red-700 text-white text-sm font-semibold rounded-lg transition-colors"
|
||||||
|
@click="handleCancelReturn"
|
||||||
|
>
|
||||||
|
<GoogleIcon name="cancel" class="text-lg" />
|
||||||
|
Cancelar Devolución
|
||||||
|
</button>
|
||||||
|
<div v-else></div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="px-4 py-2 bg-gray-100 hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-300 text-sm font-semibold rounded-lg transition-colors"
|
||||||
|
@click="handleClose"
|
||||||
|
>
|
||||||
|
Cerrar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</template>
|
||||||
255
src/pages/POS/Returns/SelectSale.vue
Normal file
255
src/pages/POS/Returns/SelectSale.vue
Normal file
@ -0,0 +1,255 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, watch } from 'vue';
|
||||||
|
import { useSearcher, apiURL } from '@Services/Api';
|
||||||
|
import { formatCurrency, formatDate } from '@/utils/formatters';
|
||||||
|
|
||||||
|
import Modal from '@Holos/Modal.vue';
|
||||||
|
import SearcherHead from '@Holos/Searcher.vue';
|
||||||
|
import Table from '@Holos/Table.vue';
|
||||||
|
import GoogleIcon from '@Shared/GoogleIcon.vue';
|
||||||
|
|
||||||
|
/** Props */
|
||||||
|
const props = defineProps({
|
||||||
|
show: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Emits */
|
||||||
|
const emit = defineEmits(['close', 'select-sale']);
|
||||||
|
|
||||||
|
/** Estado */
|
||||||
|
const models = ref([]);
|
||||||
|
const selectedSale = ref(null);
|
||||||
|
|
||||||
|
/** Buscador de ventas completadas */
|
||||||
|
const searcher = useSearcher({
|
||||||
|
url: apiURL('sales'),
|
||||||
|
onSuccess: (r) => {
|
||||||
|
models.value = r.sales || r.models || { data: [], total: 0 };
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
console.error('❌ ERROR al cargar ventas:', error);
|
||||||
|
models.value = { data: [], total: 0 };
|
||||||
|
window.Notify.error('Error al cargar ventas');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Watchers */
|
||||||
|
watch(() => props.show, (isShown) => {
|
||||||
|
if (isShown) {
|
||||||
|
selectedSale.value = null;
|
||||||
|
searcher.search();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Métodos */
|
||||||
|
const handleSearch = (query) => {
|
||||||
|
searcher.search({ q: query, status: 'completed' });
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectSale = (sale) => {
|
||||||
|
selectedSale.value = sale;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isSelected = (sale) => {
|
||||||
|
return selectedSale.value?.id === sale.id;
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmSelection = () => {
|
||||||
|
if (!selectedSale.value) {
|
||||||
|
window.Notify.warning('Selecciona una venta para continuar');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
emit('select-sale', selectedSale.value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
selectedSale.value = null;
|
||||||
|
emit('close');
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Helpers de método de pago */
|
||||||
|
const getPaymentMethodLabel = (method) => {
|
||||||
|
const labels = {
|
||||||
|
cash: 'Efectivo',
|
||||||
|
card: 'Debito',
|
||||||
|
credit: 'Crédito'
|
||||||
|
};
|
||||||
|
return labels[method] || method || '-';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPaymentMethodIcon = (method) => {
|
||||||
|
const icons = {
|
||||||
|
cash: 'payments',
|
||||||
|
card: 'credit_card',
|
||||||
|
credit: 'request_quote'
|
||||||
|
};
|
||||||
|
return icons[method] || 'payment';
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Modal :show="show" max-width="4xl" @close="handleClose">
|
||||||
|
<div class="p-6">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex items-center justify-between mb-6">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="w-12 h-12 bg-indigo-100 dark:bg-indigo-900/30 rounded-xl flex items-center justify-center">
|
||||||
|
<GoogleIcon name="receipt_long" class="text-2xl text-indigo-600 dark:text-indigo-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 class="text-lg font-bold text-gray-900 dark:text-gray-100">
|
||||||
|
Seleccionar Venta
|
||||||
|
</h3>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Busca y selecciona la venta original para la devolución
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
@click="handleClose"
|
||||||
|
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
|
||||||
|
>
|
||||||
|
<GoogleIcon name="close" class="text-2xl" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Buscador -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<SearcherHead
|
||||||
|
title=""
|
||||||
|
placeholder="Buscar por folio, cliente o cajero..."
|
||||||
|
@search="handleSearch"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Venta seleccionada preview -->
|
||||||
|
<div
|
||||||
|
v-if="selectedSale"
|
||||||
|
class="mb-4 bg-indigo-50 dark:bg-indigo-900/20 border border-indigo-200 dark:border-indigo-800 rounded-lg p-4"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<GoogleIcon name="check_circle" class="text-2xl text-indigo-600 dark:text-indigo-400" />
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-indigo-600 dark:text-indigo-400">Venta seleccionada:</p>
|
||||||
|
<p class="font-semibold text-indigo-700 dark:text-indigo-300 font-mono">
|
||||||
|
#{{ selectedSale.invoice_number || selectedSale.id }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="text-lg font-bold text-indigo-700 dark:text-indigo-300">
|
||||||
|
{{ formatCurrency(selectedSale.total) }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tabla de ventas -->
|
||||||
|
<div class="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
|
||||||
|
<Table
|
||||||
|
:items="models"
|
||||||
|
:processing="searcher.processing"
|
||||||
|
@send-pagination="(page) => searcher.pagination(page)"
|
||||||
|
>
|
||||||
|
<template #head>
|
||||||
|
<th class="px-4 py-3 text-left text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase w-10">
|
||||||
|
<span class="sr-only">Seleccionar</span>
|
||||||
|
</th>
|
||||||
|
<th class="px-4 py-3 text-left text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase">
|
||||||
|
Folio
|
||||||
|
</th>
|
||||||
|
<th class="px-4 py-3 text-left text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase">
|
||||||
|
Fecha
|
||||||
|
</th>
|
||||||
|
<th class="px-4 py-3 text-left text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase">
|
||||||
|
Cajero
|
||||||
|
</th>
|
||||||
|
<th class="px-4 py-3 text-center text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase">
|
||||||
|
Método de Pago
|
||||||
|
</th>
|
||||||
|
<th class="px-4 py-3 text-right text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase">
|
||||||
|
Total
|
||||||
|
</th>
|
||||||
|
</template>
|
||||||
|
<template #body="{ items }">
|
||||||
|
<tr
|
||||||
|
v-for="sale in items"
|
||||||
|
:key="sale.id"
|
||||||
|
@click="selectSale(sale)"
|
||||||
|
class="cursor-pointer transition-colors"
|
||||||
|
:class="isSelected(sale)
|
||||||
|
? 'bg-indigo-50 dark:bg-indigo-900/30 hover:bg-indigo-100 dark:hover:bg-indigo-900/40'
|
||||||
|
: 'hover:bg-gray-50 dark:hover:bg-gray-800'"
|
||||||
|
>
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
:checked="isSelected(sale)"
|
||||||
|
@change="selectSale(sale)"
|
||||||
|
class="w-4 h-4 text-indigo-600 border-gray-300 focus:ring-indigo-500"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
<span class="text-sm font-mono font-semibold text-indigo-600 dark:text-indigo-400">
|
||||||
|
#{{ sale.invoice_number || sale.id }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
<span class="text-sm text-gray-700 dark:text-gray-300">
|
||||||
|
{{ formatDate(sale.created_at) }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
<span class="text-sm text-gray-700 dark:text-gray-300">
|
||||||
|
{{ sale.user?.name || sale.cashier?.name || '-' }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-center">
|
||||||
|
<span class="inline-flex items-center gap-1 px-2.5 py-1 text-xs font-medium rounded-full bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300">
|
||||||
|
<GoogleIcon :name="getPaymentMethodIcon(sale.payment_method)" class="text-sm" />
|
||||||
|
{{ getPaymentMethodLabel(sale.payment_method) }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-right">
|
||||||
|
<span class="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
||||||
|
{{ formatCurrency(sale.total) }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</template>
|
||||||
|
<template #empty>
|
||||||
|
<td colspan="6" class="table-cell text-center">
|
||||||
|
<div class="flex flex-col items-center justify-center py-8 text-gray-500">
|
||||||
|
<GoogleIcon name="receipt_long" class="text-6xl mb-2 opacity-50" />
|
||||||
|
<p class="font-semibold">No hay ventas disponibles</p>
|
||||||
|
<p class="text-sm mt-1">Intenta buscar con otros términos</p>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</template>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<div class="flex justify-end gap-3 mt-6 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="px-4 py-2 bg-gray-100 hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-300 text-sm font-semibold rounded-lg transition-colors"
|
||||||
|
@click="handleClose"
|
||||||
|
>
|
||||||
|
Cancelar
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
:disabled="!selectedSale"
|
||||||
|
class="px-4 py-2 bg-indigo-600 hover:bg-indigo-700 disabled:bg-indigo-400 disabled:cursor-not-allowed text-white text-sm font-semibold rounded-lg transition-colors flex items-center gap-2"
|
||||||
|
@click="confirmSelection"
|
||||||
|
>
|
||||||
|
<GoogleIcon name="arrow_forward" />
|
||||||
|
Continuar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</template>
|
||||||
@ -1,8 +1,9 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed, watch } from 'vue';
|
import { computed, watch, ref } from 'vue';
|
||||||
import ticketService from '@Services/ticketService';
|
import ticketService from '@Services/ticketService';
|
||||||
import GoogleIcon from '@Shared/GoogleIcon.vue';
|
import GoogleIcon from '@Shared/GoogleIcon.vue';
|
||||||
import Modal from '@Holos/Modal.vue';
|
import Modal from '@Holos/Modal.vue';
|
||||||
|
import CreateReturnModal from '../Returns/CreateReturn.vue';
|
||||||
|
|
||||||
/** Props */
|
/** Props */
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
@ -20,6 +21,8 @@ const props = defineProps({
|
|||||||
const emit = defineEmits(['close', 'cancel-sale']);
|
const emit = defineEmits(['close', 'cancel-sale']);
|
||||||
|
|
||||||
/** Computados */
|
/** Computados */
|
||||||
|
const showReturnModal = ref(false);
|
||||||
|
|
||||||
const saleDetails = computed(() => {
|
const saleDetails = computed(() => {
|
||||||
return props.sale?.details || [];
|
return props.sale?.details || [];
|
||||||
});
|
});
|
||||||
@ -74,6 +77,10 @@ const canCancel = computed(() => {
|
|||||||
return props.sale?.status === 'completed';
|
return props.sale?.status === 'completed';
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const hasReturns = computed(() => {
|
||||||
|
return props.sale?.returns && props.sale.returns.length > 0;
|
||||||
|
});
|
||||||
|
|
||||||
/** Métodos */
|
/** Métodos */
|
||||||
const formatCurrency = (amount) => {
|
const formatCurrency = (amount) => {
|
||||||
return new Intl.NumberFormat('es-MX', {
|
return new Intl.NumberFormat('es-MX', {
|
||||||
@ -107,6 +114,16 @@ const handleDownloadTicket = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleOpenReturn = () => {
|
||||||
|
showReturnModal.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReturnCreated = () => {
|
||||||
|
showReturnModal.value = false;
|
||||||
|
// Close detail modal to refresh list or prevent stale data
|
||||||
|
emit('close');
|
||||||
|
};
|
||||||
|
|
||||||
/** Watchers */
|
/** Watchers */
|
||||||
watch(() => props.show, () => {
|
watch(() => props.show, () => {
|
||||||
// Modal opened
|
// Modal opened
|
||||||
@ -266,6 +283,52 @@ watch(() => props.show, () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Devoluciones Relacionadas -->
|
||||||
|
<div v-if="hasReturns" class="bg-orange-50 dark:bg-orange-900/20 border border-orange-200 dark:border-orange-800 rounded-xl p-5">
|
||||||
|
<div class="flex items-center gap-2 mb-4">
|
||||||
|
<GoogleIcon name="assignment_return" class="text-orange-600 dark:text-orange-400 text-xl" />
|
||||||
|
<h3 class="text-sm font-bold text-orange-900 dark:text-orange-100 uppercase tracking-wide">
|
||||||
|
Devoluciones ({{ sale.returns.length }})
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div
|
||||||
|
v-for="returnItem in sale.returns"
|
||||||
|
:key="returnItem.id"
|
||||||
|
class="flex items-center justify-between p-3 bg-white dark:bg-gray-800 rounded-lg border border-orange-200 dark:border-orange-700"
|
||||||
|
>
|
||||||
|
<div class="flex-1">
|
||||||
|
<div class="flex items-center gap-2 mb-1">
|
||||||
|
<p class="text-sm font-mono font-bold text-orange-900 dark:text-orange-100">
|
||||||
|
{{ returnItem.return_number || `DEV-${String(returnItem.id).padStart(6, '0')}` }}
|
||||||
|
</p>
|
||||||
|
<span v-if="returnItem.deleted_at"
|
||||||
|
class="px-2 py-0.5 text-xs font-semibold rounded-full bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300">
|
||||||
|
Cancelada
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{{ new Intl.DateTimeFormat('es-MX', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
}).format(new Date(returnItem.created_at)) }}
|
||||||
|
</p>
|
||||||
|
<p v-if="returnItem.reason_text" class="text-xs text-gray-600 dark:text-gray-400 mt-1">
|
||||||
|
{{ returnItem.reason_text }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="text-right">
|
||||||
|
<p class="text-lg font-bold text-orange-600 dark:text-orange-400">
|
||||||
|
-{{ formatCurrency(returnItem.total) }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Totales -->
|
<!-- Totales -->
|
||||||
<div class="bg-gradient-to-br from-indigo-50 to-indigo-100 dark:from-indigo-900/20 dark:to-indigo-800/20 rounded-xl p-5 border border-indigo-200 dark:border-indigo-800">
|
<div class="bg-gradient-to-br from-indigo-50 to-indigo-100 dark:from-indigo-900/20 dark:to-indigo-800/20 rounded-xl p-5 border border-indigo-200 dark:border-indigo-800">
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
@ -318,12 +381,20 @@ watch(() => props.show, () => {
|
|||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="flex items-center gap-2 px-4 py-2.5 bg-green-600 hover:bg-green-700 text-white text-sm font-semibold rounded-lg transition-colors shadow-sm"
|
class="flex items-center gap-2 px-4 py-2 bg-green-600 hover:bg-green-700 text-white text-sm font-semibold rounded-lg transition-colors shadow-sm"
|
||||||
@click="handleDownloadTicket"
|
@click="handleDownloadTicket"
|
||||||
>
|
>
|
||||||
<GoogleIcon name="download" class="text-lg" />
|
<GoogleIcon name="download" class="text-lg" />
|
||||||
Descargar Ticket
|
Descargar Ticket
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex items-center gap-2 px-4 py-2 bg-red-600 hover:bg-red-700 text-white text-sm font-semibold rounded-lg transition-colors shadow-sm"
|
||||||
|
@click="handleOpenReturn"
|
||||||
|
>
|
||||||
|
<GoogleIcon name="assignment_return" class="text-lg" />
|
||||||
|
Devolución
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="px-4 py-2.5 text-sm font-semibold text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700 rounded-lg transition-colors"
|
class="px-4 py-2.5 text-sm font-semibold text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700 rounded-lg transition-colors"
|
||||||
@ -335,4 +406,12 @@ watch(() => props.show, () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
<!-- Modal de Devolución -->
|
||||||
|
<CreateReturnModal
|
||||||
|
:show="showReturnModal"
|
||||||
|
:sale="sale"
|
||||||
|
@close="showReturnModal = false"
|
||||||
|
@created="handleReturnCreated"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@ -78,6 +78,11 @@ const router = createRouter({
|
|||||||
name: 'pos.sales.index',
|
name: 'pos.sales.index',
|
||||||
component: () => import('@Pages/POS/Sales/Index.vue')
|
component: () => import('@Pages/POS/Sales/Index.vue')
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'returns',
|
||||||
|
name: 'pos.returns.index',
|
||||||
|
component: () => import('@Pages/POS/Returns/Index.vue')
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'clients',
|
path: 'clients',
|
||||||
name: 'pos.clients.index',
|
name: 'pos.clients.index',
|
||||||
|
|||||||
100
src/services/returnsService.js
Normal file
100
src/services/returnsService.js
Normal file
@ -0,0 +1,100 @@
|
|||||||
|
import { api, apiURL } from '@Services/Api';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Servicio para gestionar devoluciones
|
||||||
|
*/
|
||||||
|
const returnsService = {
|
||||||
|
/**
|
||||||
|
* Obtener lista de devoluciones con paginación
|
||||||
|
* @param {Object} filters - Filtros de búsqueda (page, q, status, etc.)
|
||||||
|
* @returns {Promise}
|
||||||
|
*/
|
||||||
|
async getReturns(filters = {}) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
api.get(apiURL('returns'), {
|
||||||
|
params: filters,
|
||||||
|
onSuccess: (response) => {
|
||||||
|
resolve(response.returns || response.models || response);
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtener detalle completo de una devolución con items y seriales
|
||||||
|
* @param {Number} returnId - ID de la devolución
|
||||||
|
* @returns {Promise}
|
||||||
|
*/
|
||||||
|
async getReturnDetails(returnId) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
api.get(apiURL(`returns/${returnId}`), {
|
||||||
|
onSuccess: (response) => {
|
||||||
|
resolve(response.return || response.model || response);
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtener items devolvibles de una venta
|
||||||
|
* @param {Number} saleId - ID de la venta
|
||||||
|
* @returns {Promise}
|
||||||
|
*/
|
||||||
|
async getReturnableItems(saleId) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
api.get(apiURL(`sales/${saleId}/returnable`), {
|
||||||
|
onSuccess: (response) => {
|
||||||
|
resolve(response);
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Crear una nueva devolución
|
||||||
|
* @param {Object} returnData - Datos de la devolución
|
||||||
|
* @returns {Promise}
|
||||||
|
*/
|
||||||
|
async createReturn(returnData) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
api.post(apiURL('returns'), {
|
||||||
|
data: returnData,
|
||||||
|
onSuccess: (response) => {
|
||||||
|
resolve(response.return || response.model || response);
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cancelar una devolución
|
||||||
|
* @param {Number} returnId - ID de la devolución
|
||||||
|
* @returns {Promise}
|
||||||
|
*/
|
||||||
|
async cancelReturn(returnId) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
api.put(apiURL(`returns/${returnId}/cancel`), {
|
||||||
|
onSuccess: (response) => {
|
||||||
|
resolve(response.return || response.model || response);
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default returnsService;
|
||||||
@ -43,14 +43,19 @@ const useCart = defineStore('cart', {
|
|||||||
const existingItem = this.items.find(item => item.inventory_id === product.id);
|
const existingItem = this.items.find(item => item.inventory_id === product.id);
|
||||||
|
|
||||||
if (existingItem) {
|
if (existingItem) {
|
||||||
// Si ya existe, incrementar cantidad
|
// Si ya existe y tiene seriales, abrir selector de nuevo
|
||||||
|
if (existingItem.track_serials) {
|
||||||
|
window.Notify.warning('Este producto requiere selección de seriales');
|
||||||
|
// Emitir evento para que Point.vue abra el selector
|
||||||
|
window.dispatchEvent(new CustomEvent('open-serial-selector', {
|
||||||
|
detail: { productId: product.id }
|
||||||
|
}));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Si NO tiene seriales, incrementar normalmente
|
||||||
if (existingItem.quantity < product.stock) {
|
if (existingItem.quantity < product.stock) {
|
||||||
existingItem.quantity++;
|
existingItem.quantity++;
|
||||||
// Si tiene seriales, limpiar para que el usuario vuelva a seleccionar
|
|
||||||
if (existingItem.has_serials) {
|
|
||||||
existingItem.serial_numbers = [];
|
|
||||||
existingItem.serial_selection_mode = null;
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
window.Notify.warning('No hay suficiente stock disponible');
|
window.Notify.warning('No hay suficiente stock disponible');
|
||||||
}
|
}
|
||||||
@ -65,7 +70,7 @@ const useCart = defineStore('cart', {
|
|||||||
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
|
||||||
has_serials: product.has_serials || false,
|
track_serials: product.track_serials || false,
|
||||||
serial_numbers: serialConfig?.serialNumbers || [],
|
serial_numbers: serialConfig?.serialNumbers || [],
|
||||||
serial_selection_mode: serialConfig?.selectionMode || null
|
serial_selection_mode: serialConfig?.selectionMode || null
|
||||||
});
|
});
|
||||||
@ -78,11 +83,12 @@ const useCart = defineStore('cart', {
|
|||||||
const newSerials = serialConfig.serialNumbers || [];
|
const newSerials = serialConfig.serialNumbers || [];
|
||||||
|
|
||||||
if (existingItem) {
|
if (existingItem) {
|
||||||
// Combinar seriales existentes con los nuevos
|
// COMBINAR seriales con los existentes
|
||||||
const combinedSerials = [...existingItem.serial_numbers, ...newSerials];
|
const combinedSerials = [...existingItem.serial_numbers, ...newSerials];
|
||||||
existingItem.serial_numbers = combinedSerials;
|
// Eliminar duplicados
|
||||||
existingItem.quantity = combinedSerials.length;
|
existingItem.serial_numbers = [...new Set(combinedSerials)];
|
||||||
} else {
|
existingItem.quantity = existingItem.serial_numbers.length;
|
||||||
|
} else {
|
||||||
// Agregar nuevo item con seriales
|
// Agregar nuevo item con seriales
|
||||||
this.items.push({
|
this.items.push({
|
||||||
inventory_id: product.id,
|
inventory_id: product.id,
|
||||||
@ -92,7 +98,7 @@ const useCart = defineStore('cart', {
|
|||||||
unit_price: parseFloat(product.price?.retail_price || 0),
|
unit_price: parseFloat(product.price?.retail_price || 0),
|
||||||
tax_rate: parseFloat(product.price?.tax || 16),
|
tax_rate: parseFloat(product.price?.tax || 16),
|
||||||
max_stock: product.stock,
|
max_stock: product.stock,
|
||||||
has_serials: true,
|
track_serials: product.track_serials,
|
||||||
serial_numbers: newSerials
|
serial_numbers: newSerials
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -110,14 +116,14 @@ const useCart = defineStore('cart', {
|
|||||||
// Obtener seriales ya seleccionados (para excluir del selector)
|
// Obtener seriales ya seleccionados (para excluir del selector)
|
||||||
getSelectedSerials() {
|
getSelectedSerials() {
|
||||||
return this.items
|
return this.items
|
||||||
.filter(item => item.has_serials && item.serial_numbers?.length > 0)
|
.filter(item => item.track_serials && item.serial_numbers?.length > 0)
|
||||||
.flatMap(item => item.serial_numbers);
|
.flatMap(item => item.serial_numbers);
|
||||||
},
|
},
|
||||||
|
|
||||||
// Verificar si un item necesita selección de seriales
|
// Verificar si un item necesita selección de seriales
|
||||||
needsSerialSelection(inventoryId) {
|
needsSerialSelection(inventoryId) {
|
||||||
const item = this.items.find(i => i.inventory_id === inventoryId);
|
const item = this.items.find(i => i.inventory_id === inventoryId);
|
||||||
if (!item || !item.has_serials) return false;
|
if (!item || !item.track_serials) return false;
|
||||||
// Necesita selección si es manual y no tiene suficientes seriales
|
// Necesita selección si es manual y no tiene suficientes seriales
|
||||||
if (item.serial_selection_mode === 'manual') {
|
if (item.serial_selection_mode === 'manual') {
|
||||||
return (item.serial_numbers?.length || 0) < item.quantity;
|
return (item.serial_numbers?.length || 0) < item.quantity;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user