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
|
||||
*.sln
|
||||
*.sw?
|
||||
CLAUDE.md
|
||||
@ -11,7 +11,7 @@ const props = defineProps({
|
||||
});
|
||||
|
||||
/** Emits */
|
||||
const emit = defineEmits(['update-quantity', 'remove']);
|
||||
const emit = defineEmits(['update-quantity', 'remove', 'select-serials']);
|
||||
|
||||
/** Computados */
|
||||
const formattedUnitPrice = computed(() => {
|
||||
@ -29,7 +29,20 @@ const formattedSubtotal = computed(() => {
|
||||
}).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 */
|
||||
const increment = () => {
|
||||
|
||||
@ -458,6 +458,7 @@ export default {
|
||||
cashRegister: 'Caja',
|
||||
point: 'Punto de Venta',
|
||||
sales: 'Ventas',
|
||||
returns: 'Devoluciones',
|
||||
clients: 'Clientes',
|
||||
billingRequests: 'Solicitudes de Facturación'
|
||||
},
|
||||
|
||||
@ -57,6 +57,11 @@ onMounted(() => {
|
||||
name="pos.sales"
|
||||
to="pos.sales.index"
|
||||
/>
|
||||
<Link
|
||||
icon="Sync"
|
||||
name="pos.returns"
|
||||
to="pos.returns.index"
|
||||
/>
|
||||
<Link
|
||||
icon="accessibility"
|
||||
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 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 hasDifference = computed(() => Math.abs(difference.value) > 0.01);
|
||||
|
||||
@ -128,6 +138,44 @@ const handleClose = () => {
|
||||
</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 -->
|
||||
<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">
|
||||
@ -159,11 +207,27 @@ const handleClose = () => {
|
||||
</div>
|
||||
|
||||
<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">
|
||||
= ${{ (cashSales || 0).toLocaleString('es-MX', { minimumFractionDigits: 2 }) }}
|
||||
</span>
|
||||
</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 class="border-t-2 border-gray-300 dark:border-gray-600 pt-3">
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { onMounted, ref, computed } from 'vue';
|
||||
import { onMounted, onUnmounted, ref, computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useSearcher, apiURL } from '@Services/Api';
|
||||
import { page } from '@Services/Page';
|
||||
@ -7,6 +7,7 @@ import { formatCurrency } from '@/utils/formatters';
|
||||
import useCart from '@Stores/cart';
|
||||
import salesService from '@Services/salesService';
|
||||
import ticketService from '@Services/ticketService';
|
||||
import serialService from '@Services/serialService';
|
||||
|
||||
import GoogleIcon from '@Shared/GoogleIcon.vue';
|
||||
import ProductCard from '@Components/POS/ProductCard.vue';
|
||||
@ -63,10 +64,15 @@ const filteredProducts = computed(() => {
|
||||
});
|
||||
|
||||
/** Métodos */
|
||||
const addToCart = (product) => {
|
||||
const addToCart = async (product) => {
|
||||
try {
|
||||
// Si el producto tiene seriales, mostrar selector
|
||||
if (product.has_serials) {
|
||||
const response = await serialService.getAvailableSerials(product.id);
|
||||
|
||||
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;
|
||||
showSerialSelector.value = true;
|
||||
return;
|
||||
@ -75,7 +81,9 @@ const addToCart = (product) => {
|
||||
cart.addProduct(product);
|
||||
window.Notify.success(`${product.name} agregado al carrito`);
|
||||
} 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,
|
||||
unit_price: item.unit_price,
|
||||
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
|
||||
if (paymentData.paymentMethod === 'cash' && paymentData.cashReceived) {
|
||||
saleData.cash_received = parseFloat(paymentData.cashReceived.toFixed(2));
|
||||
@ -265,9 +272,25 @@ const closeClientModal = () => {
|
||||
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 */
|
||||
onMounted(() => {
|
||||
searcher.search();
|
||||
// Escuchar evento personalizado para abrir selector de seriales
|
||||
window.addEventListener('open-serial-selector', handleOpenSerialSelector);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('open-serial-selector', handleOpenSerialSelector);
|
||||
});
|
||||
</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>
|
||||
import { computed, watch } from 'vue';
|
||||
import { computed, watch, ref } from 'vue';
|
||||
import ticketService from '@Services/ticketService';
|
||||
import GoogleIcon from '@Shared/GoogleIcon.vue';
|
||||
import Modal from '@Holos/Modal.vue';
|
||||
import CreateReturnModal from '../Returns/CreateReturn.vue';
|
||||
|
||||
/** Props */
|
||||
const props = defineProps({
|
||||
@ -20,6 +21,8 @@ const props = defineProps({
|
||||
const emit = defineEmits(['close', 'cancel-sale']);
|
||||
|
||||
/** Computados */
|
||||
const showReturnModal = ref(false);
|
||||
|
||||
const saleDetails = computed(() => {
|
||||
return props.sale?.details || [];
|
||||
});
|
||||
@ -74,6 +77,10 @@ const canCancel = computed(() => {
|
||||
return props.sale?.status === 'completed';
|
||||
});
|
||||
|
||||
const hasReturns = computed(() => {
|
||||
return props.sale?.returns && props.sale.returns.length > 0;
|
||||
});
|
||||
|
||||
/** Métodos */
|
||||
const formatCurrency = (amount) => {
|
||||
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 */
|
||||
watch(() => props.show, () => {
|
||||
// Modal opened
|
||||
@ -266,6 +283,52 @@ watch(() => props.show, () => {
|
||||
</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 -->
|
||||
<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">
|
||||
@ -318,12 +381,20 @@ watch(() => props.show, () => {
|
||||
<div class="flex items-center gap-2">
|
||||
<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"
|
||||
>
|
||||
<GoogleIcon name="download" class="text-lg" />
|
||||
Descargar Ticket
|
||||
</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
|
||||
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"
|
||||
@ -335,4 +406,12 @@ watch(() => props.show, () => {
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<!-- Modal de Devolución -->
|
||||
<CreateReturnModal
|
||||
:show="showReturnModal"
|
||||
:sale="sale"
|
||||
@close="showReturnModal = false"
|
||||
@created="handleReturnCreated"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@ -78,6 +78,11 @@ const router = createRouter({
|
||||
name: 'pos.sales.index',
|
||||
component: () => import('@Pages/POS/Sales/Index.vue')
|
||||
},
|
||||
{
|
||||
path: 'returns',
|
||||
name: 'pos.returns.index',
|
||||
component: () => import('@Pages/POS/Returns/Index.vue')
|
||||
},
|
||||
{
|
||||
path: 'clients',
|
||||
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);
|
||||
|
||||
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) {
|
||||
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 {
|
||||
window.Notify.warning('No hay suficiente stock disponible');
|
||||
}
|
||||
@ -65,7 +70,7 @@ const useCart = defineStore('cart', {
|
||||
tax_rate: parseFloat(product.price?.tax || 16),
|
||||
max_stock: product.stock,
|
||||
// Campos para seriales
|
||||
has_serials: product.has_serials || false,
|
||||
track_serials: product.track_serials || false,
|
||||
serial_numbers: serialConfig?.serialNumbers || [],
|
||||
serial_selection_mode: serialConfig?.selectionMode || null
|
||||
});
|
||||
@ -78,11 +83,12 @@ const useCart = defineStore('cart', {
|
||||
const newSerials = serialConfig.serialNumbers || [];
|
||||
|
||||
if (existingItem) {
|
||||
// Combinar seriales existentes con los nuevos
|
||||
const combinedSerials = [...existingItem.serial_numbers, ...newSerials];
|
||||
existingItem.serial_numbers = combinedSerials;
|
||||
existingItem.quantity = combinedSerials.length;
|
||||
} else {
|
||||
// COMBINAR seriales con los existentes
|
||||
const combinedSerials = [...existingItem.serial_numbers, ...newSerials];
|
||||
// Eliminar duplicados
|
||||
existingItem.serial_numbers = [...new Set(combinedSerials)];
|
||||
existingItem.quantity = existingItem.serial_numbers.length;
|
||||
} else {
|
||||
// Agregar nuevo item con seriales
|
||||
this.items.push({
|
||||
inventory_id: product.id,
|
||||
@ -92,7 +98,7 @@ const useCart = defineStore('cart', {
|
||||
unit_price: parseFloat(product.price?.retail_price || 0),
|
||||
tax_rate: parseFloat(product.price?.tax || 16),
|
||||
max_stock: product.stock,
|
||||
has_serials: true,
|
||||
track_serials: product.track_serials,
|
||||
serial_numbers: newSerials
|
||||
});
|
||||
}
|
||||
@ -110,14 +116,14 @@ const useCart = defineStore('cart', {
|
||||
// Obtener seriales ya seleccionados (para excluir del selector)
|
||||
getSelectedSerials() {
|
||||
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);
|
||||
},
|
||||
|
||||
// Verificar si un item necesita selección de seriales
|
||||
needsSerialSelection(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
|
||||
if (item.serial_selection_mode === 'manual') {
|
||||
return (item.serial_numbers?.length || 0) < item.quantity;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user