feat: añadir módulo de devoluciones, vistas y lógica relacionada

This commit is contained in:
Juan Felipe Zapata Moreno 2026-01-27 16:36:59 -06:00
parent 83dd71f80f
commit 5c3df890e4
14 changed files with 1603 additions and 28 deletions

1
.gitignore vendored
View File

@ -25,3 +25,4 @@ notes.md
*.njsproj *.njsproj
*.sln *.sln
*.sw? *.sw?
CLAUDE.md

View File

@ -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 = () => {

View File

@ -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'
}, },

View File

@ -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"

View File

@ -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">

View File

@ -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>

View 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>

View 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>

View 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>

View 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>

View File

@ -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>

View File

@ -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',

View 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;

View File

@ -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;