WIP: importar excel
This commit is contained in:
parent
173f5417b3
commit
1daed06f35
@ -4,6 +4,7 @@ import { useRoute, useRouter } from 'vue-router';
|
|||||||
import cashRegisterService from '@Services/cashRegisterService';
|
import cashRegisterService from '@Services/cashRegisterService';
|
||||||
import salesService from '@Services/salesService';
|
import salesService from '@Services/salesService';
|
||||||
import ticketService from '@Services/ticketService';
|
import ticketService from '@Services/ticketService';
|
||||||
|
import { formatCurrency, formatDate, safeParseFloat, PAYMENT_METHODS } from '@/utils/formatters';
|
||||||
import GoogleIcon from '@Shared/GoogleIcon.vue';
|
import GoogleIcon from '@Shared/GoogleIcon.vue';
|
||||||
import SaleDetailModal from '@Pages/POS/Sales/DetailModal.vue';
|
import SaleDetailModal from '@Pages/POS/Sales/DetailModal.vue';
|
||||||
|
|
||||||
@ -21,8 +22,8 @@ const selectedSale = ref(null);
|
|||||||
/** Computed */
|
/** Computed */
|
||||||
const getSalesByMethod = (method) => {
|
const getSalesByMethod = (method) => {
|
||||||
return sales.value
|
return sales.value
|
||||||
.filter(sale => sale.payment_method === method)
|
.filter(sale => sale.status === 'completed' && sale.payment_method === method)
|
||||||
.reduce((sum, sale) => sum + parseFloat(sale.total || 0), 0);
|
.reduce((sum, sale) => sum + safeParseFloat(sale.total), 0);
|
||||||
};
|
};
|
||||||
|
|
||||||
const totalCashSales = computed(() => getSalesByMethod('cash'));
|
const totalCashSales = computed(() => getSalesByMethod('cash'));
|
||||||
@ -32,8 +33,13 @@ const totalCardSales = computed(() => totalCreditCard.value + totalDebitCard.val
|
|||||||
|
|
||||||
const difference = computed(() => {
|
const difference = computed(() => {
|
||||||
if (!cashRegister.value) return 0;
|
if (!cashRegister.value) return 0;
|
||||||
const finalCash = parseFloat(cashRegister.value.final_cash || 0);
|
// Si ya se calculó en loadData, usar ese valor
|
||||||
const initialCash = parseFloat(cashRegister.value.initial_cash || 0);
|
if (cashRegister.value.difference !== undefined) {
|
||||||
|
return safeParseFloat(cashRegister.value.difference);
|
||||||
|
}
|
||||||
|
// Fallback: calcular aquí
|
||||||
|
const finalCash = safeParseFloat(cashRegister.value.final_cash);
|
||||||
|
const initialCash = safeParseFloat(cashRegister.value.initial_cash);
|
||||||
const expectedCash = initialCash + totalCashSales.value;
|
const expectedCash = initialCash + totalCashSales.value;
|
||||||
return finalCash - expectedCash;
|
return finalCash - expectedCash;
|
||||||
});
|
});
|
||||||
@ -46,11 +52,27 @@ const loadData = async () => {
|
|||||||
const registerData = await cashRegisterService.getCashRegisterDetail(route.params.id);
|
const registerData = await cashRegisterService.getCashRegisterDetail(route.params.id);
|
||||||
cashRegister.value = registerData;
|
cashRegister.value = registerData;
|
||||||
|
|
||||||
// Cargar ventas del período
|
// Cargar ventas del período (filtradas por cash_register_id en el backend)
|
||||||
const salesData = await salesService.getSales({
|
const salesData = await salesService.getSales({
|
||||||
cash_register_id: route.params.id
|
cash_register_id: route.params.id
|
||||||
});
|
});
|
||||||
sales.value = salesData.data || [];
|
|
||||||
|
const completedSales = salesData.data.filter(sale => sale.status === 'completed');
|
||||||
|
|
||||||
|
sales.value = salesData.data || []; // Muestra todas las ventas
|
||||||
|
|
||||||
|
// Calcula solo con ventas completadas
|
||||||
|
const cashSales = completedSales
|
||||||
|
.filter(s => s.payment_method === 'cash')
|
||||||
|
.reduce((sum, s) => sum + safeParseFloat(s.total), 0);
|
||||||
|
|
||||||
|
const expectedCash = safeParseFloat(cashRegister.value.initial_cash) + cashSales;
|
||||||
|
const calculatedDifference = safeParseFloat(cashRegister.value.final_cash) - expectedCash;
|
||||||
|
|
||||||
|
// Actualiza los valores calculados
|
||||||
|
cashRegister.value.expected_cash = expectedCash;
|
||||||
|
cashRegister.value.difference = calculatedDifference;
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
window.Notify.error('Error al cargar el detalle del corte');
|
window.Notify.error('Error al cargar el detalle del corte');
|
||||||
} finally {
|
} finally {
|
||||||
@ -89,24 +111,6 @@ const downloadTicket = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatCurrency = (amount) => {
|
|
||||||
return new Intl.NumberFormat('es-MX', {
|
|
||||||
style: 'currency',
|
|
||||||
currency: 'MXN'
|
|
||||||
}).format(amount || 0);
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatDate = (dateString) => {
|
|
||||||
if (!dateString) return '-';
|
|
||||||
return new Date(dateString).toLocaleString('es-MX', {
|
|
||||||
year: 'numeric',
|
|
||||||
month: 'short',
|
|
||||||
day: 'numeric',
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit'
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const getDifferenceColor = () => {
|
const getDifferenceColor = () => {
|
||||||
const diff = difference.value;
|
const diff = difference.value;
|
||||||
if (Math.abs(diff) < 0.01) return 'text-gray-600 dark:text-gray-400';
|
if (Math.abs(diff) < 0.01) return 'text-gray-600 dark:text-gray-400';
|
||||||
@ -120,12 +124,7 @@ const getDifferenceIcon = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const getPaymentMethodLabel = (method) => {
|
const getPaymentMethodLabel = (method) => {
|
||||||
const methods = {
|
return PAYMENT_METHODS[method] || method;
|
||||||
cash: 'Efectivo',
|
|
||||||
credit_card: 'Tarjeta de Crédito',
|
|
||||||
debit_card: 'Tarjeta de Débito'
|
|
||||||
};
|
|
||||||
return methods[method] || method;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const getPaymentMethodIcon = (method) => {
|
const getPaymentMethodIcon = (method) => {
|
||||||
@ -210,7 +209,7 @@ onMounted(() => {
|
|||||||
<div>
|
<div>
|
||||||
<p class="text-xs text-gray-500 dark:text-gray-400 mb-1">Apertura</p>
|
<p class="text-xs text-gray-500 dark:text-gray-400 mb-1">Apertura</p>
|
||||||
<p class="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
<p class="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
||||||
{{ formatDate(cashRegister.opened_at) }}
|
{{ formatDate(cashRegister.opened_at, { includeTime: true }) }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -218,7 +217,7 @@ onMounted(() => {
|
|||||||
<div>
|
<div>
|
||||||
<p class="text-xs text-gray-500 dark:text-gray-400 mb-1">Cierre</p>
|
<p class="text-xs text-gray-500 dark:text-gray-400 mb-1">Cierre</p>
|
||||||
<p class="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
<p class="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
||||||
{{ formatDate(cashRegister.closed_at) }}
|
{{ formatDate(cashRegister.closed_at, { includeTime: true }) }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -304,7 +303,6 @@ onMounted(() => {
|
|||||||
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden">
|
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||||
<div class="p-6 border-b border-gray-200 dark:border-gray-700">
|
<div class="p-6 border-b border-gray-200 dark:border-gray-700">
|
||||||
<h2 class="text-xl font-bold text-gray-900 dark:text-gray-100 flex items-center gap-2">
|
<h2 class="text-xl font-bold text-gray-900 dark:text-gray-100 flex items-center gap-2">
|
||||||
<GoogleIcon name="receipt" class="text-2xl text-indigo-600" />
|
|
||||||
Ventas Realizadas
|
Ventas Realizadas
|
||||||
<span class="text-sm font-normal text-gray-500 dark:text-gray-400">
|
<span class="text-sm font-normal text-gray-500 dark:text-gray-400">
|
||||||
({{ sales.length }} transacciones)
|
({{ sales.length }} transacciones)
|
||||||
@ -321,10 +319,11 @@ onMounted(() => {
|
|||||||
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
<thead class="bg-gray-50 dark:bg-gray-900">
|
<thead class="bg-gray-50 dark:bg-gray-900">
|
||||||
<tr>
|
<tr>
|
||||||
<th class="px-6 py-3 text-left text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase">Folio</th>
|
<th class="px-6 py-3 text-center text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase">Folio</th>
|
||||||
<th class="px-6 py-3 text-left text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase">Hora</th>
|
<th class="px-6 py-3 text-center text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase">Hora</th>
|
||||||
<th class="px-6 py-3 text-left text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase">Método</th>
|
<th class="px-6 py-3 text-center text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase">Método</th>
|
||||||
<th class="px-6 py-3 text-right text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase">Total</th>
|
<th class="px-6 py-3 text-center text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase">Estado</th>
|
||||||
|
<th class="px-6 py-3 text-center text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase">Total</th>
|
||||||
<th class="px-6 py-3 text-center text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase">Acciones</th>
|
<th class="px-6 py-3 text-center text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase">Acciones</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@ -334,12 +333,12 @@ onMounted(() => {
|
|||||||
:key="sale.id"
|
:key="sale.id"
|
||||||
class="hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
|
class="hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
|
||||||
>
|
>
|
||||||
<td class="px-6 py-4 whitespace-nowrap">
|
<td class="px-6 py-4 whitespace-nowrap text-center">
|
||||||
<span class="text-sm font-mono font-semibold text-gray-900 dark:text-gray-100">
|
<span class="text-sm font-mono font-semibold text-gray-900 dark:text-gray-100">
|
||||||
{{ sale.invoice_number || `#${String(sale.id).padStart(6, '0')}` }}
|
{{ sale.invoice_number || `#${String(sale.id).padStart(6, '0')}` }}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-6 py-4 whitespace-nowrap">
|
<td class="px-6 py-4 whitespace-nowrap text-center">
|
||||||
<span class="text-sm text-gray-700 dark:text-gray-300">
|
<span class="text-sm text-gray-700 dark:text-gray-300">
|
||||||
{{ new Date(sale.created_at).toLocaleTimeString('es-MX', {
|
{{ new Date(sale.created_at).toLocaleTimeString('es-MX', {
|
||||||
hour: '2-digit',
|
hour: '2-digit',
|
||||||
@ -347,7 +346,7 @@ onMounted(() => {
|
|||||||
}) }}
|
}) }}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-6 py-4 whitespace-nowrap">
|
<td class="px-6 py-4 whitespace-nowrap text-center">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<GoogleIcon
|
<GoogleIcon
|
||||||
:name="getPaymentMethodIcon(sale.payment_method)"
|
:name="getPaymentMethodIcon(sale.payment_method)"
|
||||||
@ -358,7 +357,18 @@ onMounted(() => {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-6 py-4 whitespace-nowrap text-right">
|
<td class="px-6 py-4 whitespace-nowrap text-center">
|
||||||
|
<span
|
||||||
|
class="inline-flex px-3 py-1 text-xs font-semibold rounded-full"
|
||||||
|
:class="{
|
||||||
|
'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400': sale.status === 'completed',
|
||||||
|
'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400': sale.status === 'cancelled'
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
{{ sale.status === 'completed' ? 'Completada' : 'Cancelada' }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-center">
|
||||||
<span class="text-sm font-bold text-gray-900 dark:text-gray-100">
|
<span class="text-sm font-bold text-gray-900 dark:text-gray-100">
|
||||||
{{ formatCurrency(sale.total) }}
|
{{ formatCurrency(sale.total) }}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@ -178,9 +178,6 @@ onMounted(() => {
|
|||||||
<p class="text-sm font-bold text-blue-600 dark:text-blue-400">
|
<p class="text-sm font-bold text-blue-600 dark:text-blue-400">
|
||||||
${{ parseFloat(register.total_sales || 0).toLocaleString('es-MX', { minimumFractionDigits: 2 }) }}
|
${{ parseFloat(register.total_sales || 0).toLocaleString('es-MX', { minimumFractionDigits: 2 }) }}
|
||||||
</p>
|
</p>
|
||||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
|
||||||
{{ register.transaction_count || 0 }} ventas
|
|
||||||
</p>
|
|
||||||
</td>
|
</td>
|
||||||
<td class="px-6 py-4 whitespace-nowrap text-right">
|
<td class="px-6 py-4 whitespace-nowrap text-right">
|
||||||
<p
|
<p
|
||||||
|
|||||||
284
src/pages/POS/Inventory/ImportModal.vue
Normal file
284
src/pages/POS/Inventory/ImportModal.vue
Normal file
@ -0,0 +1,284 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import { apiURL } from '@Services/Api';
|
||||||
|
import GoogleIcon from '@Shared/GoogleIcon.vue';
|
||||||
|
|
||||||
|
/** Props */
|
||||||
|
const props = defineProps({
|
||||||
|
show: Boolean
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Eventos */
|
||||||
|
const emit = defineEmits(['close', 'imported']);
|
||||||
|
|
||||||
|
/** Estado */
|
||||||
|
const selectedFile = ref(null);
|
||||||
|
const uploading = ref(false);
|
||||||
|
const importResults = ref(null);
|
||||||
|
const fileInput = ref(null);
|
||||||
|
|
||||||
|
/** Métodos */
|
||||||
|
const handleFileSelect = (event) => {
|
||||||
|
const file = event.target.files[0];
|
||||||
|
|
||||||
|
if (!file) {
|
||||||
|
selectedFile.value = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validar extensión
|
||||||
|
const validExtensions = ['.xlsx', '.xls'];
|
||||||
|
const fileExtension = file.name.substring(file.name.lastIndexOf('.')).toLowerCase();
|
||||||
|
|
||||||
|
if (!validExtensions.includes(fileExtension)) {
|
||||||
|
window.Notify.error('Por favor selecciona un archivo Excel (.xlsx o .xls)');
|
||||||
|
selectedFile.value = null;
|
||||||
|
event.target.value = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
selectedFile.value = file;
|
||||||
|
importResults.value = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const downloadTemplate = async () => {
|
||||||
|
try {
|
||||||
|
window.Notify.info('Descargando plantilla...');
|
||||||
|
|
||||||
|
const response = await fetch(apiURL('inventario/template/download'), {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${sessionStorage.token}`,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Error al descargar la plantilla');
|
||||||
|
}
|
||||||
|
|
||||||
|
const blob = await response.blob();
|
||||||
|
const url = window.URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.download = 'plantilla_productos.xlsx';
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
|
||||||
|
window.Notify.success('Plantilla descargada exitosamente');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error:', error);
|
||||||
|
window.Notify.error('Error al descargar la plantilla');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const importProducts = async () => {
|
||||||
|
if (!selectedFile.value) {
|
||||||
|
window.Notify.warning('Por favor selecciona un archivo');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
uploading.value = true;
|
||||||
|
importResults.value = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', selectedFile.value);
|
||||||
|
|
||||||
|
const response = await fetch(apiURL('inventario/import'), {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${sessionStorage.token}`,
|
||||||
|
'Accept': 'application/json',
|
||||||
|
},
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (response.ok && data.status === 'success') {
|
||||||
|
importResults.value = data.data;
|
||||||
|
|
||||||
|
const { imported, skipped, errors } = data.data;
|
||||||
|
|
||||||
|
if (imported > 0) {
|
||||||
|
window.Notify.success(`${imported} producto(s) importado(s) exitosamente`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (skipped > 0) {
|
||||||
|
window.Notify.warning(`${skipped} producto(s) omitido(s)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (errors && errors.length > 0) {
|
||||||
|
console.error('Errores de importación:', errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resetear formulario
|
||||||
|
selectedFile.value = null;
|
||||||
|
if (fileInput.value) {
|
||||||
|
fileInput.value.value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notificar al componente padre
|
||||||
|
emit('imported');
|
||||||
|
} else {
|
||||||
|
// Manejar errores de validación
|
||||||
|
if (data.data?.errors) {
|
||||||
|
const errorMessages = data.data.errors.map(err =>
|
||||||
|
`Fila ${err.row}: ${err.errors.join(', ')}`
|
||||||
|
).join('\n');
|
||||||
|
|
||||||
|
console.error('Errores de validación:', errorMessages);
|
||||||
|
window.Notify.error('Error de validación en el archivo. Revisa la consola.');
|
||||||
|
} else {
|
||||||
|
window.Notify.error(data.data?.message || 'Error al importar productos');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error:', error);
|
||||||
|
window.Notify.error('Error al importar productos');
|
||||||
|
} finally {
|
||||||
|
uploading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeModal = () => {
|
||||||
|
selectedFile.value = null;
|
||||||
|
importResults.value = null;
|
||||||
|
if (fileInput.value) {
|
||||||
|
fileInput.value.value = '';
|
||||||
|
}
|
||||||
|
emit('close');
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div v-if="show" class="fixed inset-0 z-50 overflow-y-auto">
|
||||||
|
<!-- Overlay -->
|
||||||
|
<div class="fixed inset-0 bg-black bg-opacity-50 transition-opacity" @click="closeModal"></div>
|
||||||
|
|
||||||
|
<!-- Modal -->
|
||||||
|
<div class="flex min-h-screen items-center justify-center p-4">
|
||||||
|
<div class="relative bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-2xl w-full">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex items-center justify-between p-6 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<h3 class="text-xl font-semibold text-gray-900 dark:text-gray-100">
|
||||||
|
Importar Productos desde Excel
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
@click="closeModal"
|
||||||
|
class="text-gray-400 hover:text-gray-500 dark:hover:text-gray-300 transition-colors"
|
||||||
|
>
|
||||||
|
<GoogleIcon name="close" class="text-2xl" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Body -->
|
||||||
|
<div class="p-6">
|
||||||
|
<!-- Instrucciones -->
|
||||||
|
<div class="mb-6 p-4 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg">
|
||||||
|
<div class="flex items-start gap-3">
|
||||||
|
<GoogleIcon name="info" class="text-blue-600 dark:text-blue-400 text-xl mt-0.5" />
|
||||||
|
<div class="text-sm text-blue-700 dark:text-blue-300">
|
||||||
|
<p class="font-semibold mb-2">Instrucciones:</p>
|
||||||
|
<ol class="list-decimal ml-4 space-y-1">
|
||||||
|
<li>Descarga la plantilla de Excel haciendo clic en el botón de abajo</li>
|
||||||
|
<li>Completa la plantilla con los datos de tus productos</li>
|
||||||
|
<li>Guarda el archivo y súbelo usando el botón "Seleccionar archivo"</li>
|
||||||
|
<li>Haz clic en "Importar" para procesar el archivo</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Botón Descargar Plantilla -->
|
||||||
|
<div class="mb-6">
|
||||||
|
<button
|
||||||
|
@click="downloadTemplate"
|
||||||
|
class="w-full flex items-center justify-center gap-2 px-4 py-3 bg-green-600 hover:bg-green-700 text-white font-semibold rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<GoogleIcon name="download" class="text-xl" />
|
||||||
|
Descargar Plantilla Excel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Selector de Archivo -->
|
||||||
|
<div class="mb-6">
|
||||||
|
<label class="block text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">
|
||||||
|
Archivo Excel (.xlsx, .xls)
|
||||||
|
</label>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<label class="flex-1 cursor-pointer">
|
||||||
|
<div class="flex items-center justify-center gap-2 px-4 py-3 border-2 border-dashed border-gray-300 dark:border-gray-600 rounded-lg hover:border-indigo-500 dark:hover:border-indigo-400 transition-colors">
|
||||||
|
<GoogleIcon name="upload_file" class="text-2xl text-gray-400" />
|
||||||
|
<span class="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
{{ selectedFile ? selectedFile.name : 'Seleccionar archivo Excel...' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
ref="fileInput"
|
||||||
|
type="file"
|
||||||
|
accept=".xlsx,.xls"
|
||||||
|
@change="handleFileSelect"
|
||||||
|
class="hidden"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
v-if="selectedFile"
|
||||||
|
@click="selectedFile = null; fileInput.value = ''"
|
||||||
|
class="p-3 text-red-600 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 transition-colors"
|
||||||
|
title="Eliminar archivo"
|
||||||
|
>
|
||||||
|
<GoogleIcon name="delete" class="text-xl" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Resultados de Importación -->
|
||||||
|
<div v-if="importResults" class="mb-6 p-4 bg-gray-50 dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-lg">
|
||||||
|
<h4 class="font-semibold text-gray-900 dark:text-gray-100 mb-3">Resultados de la Importación</h4>
|
||||||
|
<div class="space-y-2 text-sm">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="text-gray-600 dark:text-gray-400">Productos importados:</span>
|
||||||
|
<span class="font-semibold text-green-600 dark:text-green-400">{{ importResults.imported }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="importResults.skipped > 0" class="flex items-center justify-between">
|
||||||
|
<span class="text-gray-600 dark:text-gray-400">Productos omitidos:</span>
|
||||||
|
<span class="font-semibold text-yellow-600 dark:text-yellow-400">{{ importResults.skipped }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="importResults.errors && importResults.errors.length > 0">
|
||||||
|
<p class="text-red-600 dark:text-red-400 font-semibold mb-1">Errores:</p>
|
||||||
|
<ul class="list-disc ml-5 text-red-600 dark:text-red-400">
|
||||||
|
<li v-for="(error, index) in importResults.errors" :key="index">{{ error }}</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<div class="flex items-center justify-end gap-3 p-6 border-t border-gray-200 dark:border-gray-700">
|
||||||
|
<button
|
||||||
|
@click="closeModal"
|
||||||
|
class="px-4 py-2 text-sm font-semibold text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
Cancelar
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click="importProducts"
|
||||||
|
:disabled="!selectedFile || uploading"
|
||||||
|
class="flex items-center gap-2 px-4 py-2 bg-indigo-600 hover:bg-indigo-700 text-white text-sm font-semibold rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
<GoogleIcon
|
||||||
|
:name="uploading ? 'hourglass_empty' : 'upload'"
|
||||||
|
:class="{ 'animate-spin': uploading }"
|
||||||
|
class="text-xl"
|
||||||
|
/>
|
||||||
|
{{ uploading ? 'Importando...' : 'Importar Productos' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@ -1,6 +1,7 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { onMounted, ref } from 'vue';
|
import { onMounted, ref } from 'vue';
|
||||||
import { useSearcher, apiURL } from '@Services/Api';
|
import { useSearcher, apiURL } from '@Services/Api';
|
||||||
|
import { formatCurrency } from '@/utils/formatters';
|
||||||
|
|
||||||
import SearcherHead from '@Holos/Searcher.vue';
|
import SearcherHead from '@Holos/Searcher.vue';
|
||||||
import Table from '@Holos/Table.vue';
|
import Table from '@Holos/Table.vue';
|
||||||
@ -8,12 +9,14 @@ import GoogleIcon from '@Shared/GoogleIcon.vue';
|
|||||||
import CreateModal from './CreateModal.vue';
|
import CreateModal from './CreateModal.vue';
|
||||||
import EditModal from './EditModal.vue';
|
import EditModal from './EditModal.vue';
|
||||||
import DeleteModal from './DeleteModal.vue';
|
import DeleteModal from './DeleteModal.vue';
|
||||||
|
import ImportModal from './ImportModal.vue';
|
||||||
|
|
||||||
/** Estado */
|
/** Estado */
|
||||||
const models = ref([]);
|
const models = ref([]);
|
||||||
const showCreateModal = ref(false);
|
const showCreateModal = ref(false);
|
||||||
const showEditModal = ref(false);
|
const showEditModal = ref(false);
|
||||||
const showDeleteModal = ref(false);
|
const showDeleteModal = ref(false);
|
||||||
|
const showImportModal = ref(false);
|
||||||
const editingProduct = ref(null);
|
const editingProduct = ref(null);
|
||||||
const deletingProduct = ref(null);
|
const deletingProduct = ref(null);
|
||||||
|
|
||||||
@ -21,9 +24,11 @@ const deletingProduct = ref(null);
|
|||||||
const searcher = useSearcher({
|
const searcher = useSearcher({
|
||||||
url: apiURL('inventario'),
|
url: apiURL('inventario'),
|
||||||
onSuccess: (r) => {
|
onSuccess: (r) => {
|
||||||
models.value = r.products || [];
|
models.value = r.products || { data: [], total: 0 };
|
||||||
},
|
},
|
||||||
onError: () => models.value = []
|
onError: () => {
|
||||||
|
models.value = { data: [], total: 0 };
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const openCreateModal = () => {
|
const openCreateModal = () => {
|
||||||
@ -54,10 +59,22 @@ const closeDeleteModal = () => {
|
|||||||
deletingProduct.value = null;
|
deletingProduct.value = null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const openImportModal = () => {
|
||||||
|
showImportModal.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeImportModal = () => {
|
||||||
|
showImportModal.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
const onProductSaved = () => {
|
const onProductSaved = () => {
|
||||||
searcher.search();
|
searcher.search();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const onProductsImported = () => {
|
||||||
|
searcher.search();
|
||||||
|
};
|
||||||
|
|
||||||
const confirmDelete = async (id) => {
|
const confirmDelete = async (id) => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(apiURL(`inventario/${id}`), {
|
const response = await fetch(apiURL(`inventario/${id}`), {
|
||||||
@ -94,6 +111,14 @@ onMounted(() => {
|
|||||||
placeholder="Buscar por nombre o SKU..."
|
placeholder="Buscar por nombre o SKU..."
|
||||||
@search="(x) => searcher.search(x)"
|
@search="(x) => searcher.search(x)"
|
||||||
>
|
>
|
||||||
|
<button
|
||||||
|
class="flex items-center gap-2 px-3 py-2 bg-green-600 hover:bg-green-700 text-white text-sm font-semibold rounded-lg transition-colors shadow-sm"
|
||||||
|
@click="openImportModal"
|
||||||
|
title="Importar productos desde Excel"
|
||||||
|
>
|
||||||
|
<GoogleIcon name="upload" class="text-xl" />
|
||||||
|
Importar
|
||||||
|
</button>
|
||||||
<button
|
<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"
|
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"
|
@click="openCreateModal"
|
||||||
@ -126,6 +151,13 @@ onMounted(() => {
|
|||||||
@confirm="confirmDelete"
|
@confirm="confirmDelete"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<!-- Modal de Importar Productos -->
|
||||||
|
<ImportModal
|
||||||
|
:show="showImportModal"
|
||||||
|
@close="closeImportModal"
|
||||||
|
@imported="onProductsImported"
|
||||||
|
/>
|
||||||
|
|
||||||
<div class="pt-2 w-full">
|
<div class="pt-2 w-full">
|
||||||
<Table
|
<Table
|
||||||
:items="models"
|
:items="models"
|
||||||
@ -163,10 +195,10 @@ onMounted(() => {
|
|||||||
<td class="px-6 py-4 whitespace-nowrap text-right">
|
<td class="px-6 py-4 whitespace-nowrap text-right">
|
||||||
<div class="text-sm">
|
<div class="text-sm">
|
||||||
<p class="font-semibold text-gray-900 dark:text-gray-100">
|
<p class="font-semibold text-gray-900 dark:text-gray-100">
|
||||||
${{ parseFloat(model.price?.retail_price || 0).toLocaleString('es-MX', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) }}
|
{{ formatCurrency(model.price?.retail_price) }}
|
||||||
</p>
|
</p>
|
||||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||||
Costo: ${{ parseFloat(model.price?.cost || 0).toLocaleString('es-MX', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) }}
|
Costo: {{ formatCurrency(model.price?.cost) }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@ -2,6 +2,7 @@
|
|||||||
import { onMounted, ref } from 'vue';
|
import { onMounted, ref } from 'vue';
|
||||||
import { useSearcher, apiURL } from '@Services/Api';
|
import { useSearcher, apiURL } from '@Services/Api';
|
||||||
import ticketService from '@Services/ticketService';
|
import ticketService from '@Services/ticketService';
|
||||||
|
import { formatCurrency, formatDate, PAYMENT_METHODS } from '@/utils/formatters';
|
||||||
|
|
||||||
import SearcherHead from '@Holos/Searcher.vue';
|
import SearcherHead from '@Holos/Searcher.vue';
|
||||||
import Table from '@Holos/Table.vue';
|
import Table from '@Holos/Table.vue';
|
||||||
@ -17,10 +18,11 @@ const selectedSale = ref(null);
|
|||||||
const searcher = useSearcher({
|
const searcher = useSearcher({
|
||||||
url: apiURL('sales'),
|
url: apiURL('sales'),
|
||||||
onSuccess: (r) => {
|
onSuccess: (r) => {
|
||||||
models.value = r.sales || [];
|
models.value = r.sales || r.models || { data: [], total: 0 };
|
||||||
},
|
},
|
||||||
onError: () => {
|
onError: (error) => {
|
||||||
models.value = [];
|
console.error('❌ ERROR al cargar ventas:', error);
|
||||||
|
models.value = { data: [], total: 0 };
|
||||||
window.Notify.error('Error al cargar ventas');
|
window.Notify.error('Error al cargar ventas');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@ -64,32 +66,8 @@ const handleCancelSale = async (saleId) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatCurrency = (amount) => {
|
|
||||||
return new Intl.NumberFormat('es-MX', {
|
|
||||||
style: 'currency',
|
|
||||||
currency: 'MXN'
|
|
||||||
}).format(amount || 0);
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatDate = (dateString) => {
|
|
||||||
if (!dateString) return '-';
|
|
||||||
const date = new Date(dateString);
|
|
||||||
return new Intl.DateTimeFormat('es-MX', {
|
|
||||||
year: 'numeric',
|
|
||||||
month: '2-digit',
|
|
||||||
day: '2-digit',
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit'
|
|
||||||
}).format(date);
|
|
||||||
};
|
|
||||||
|
|
||||||
const getPaymentMethodLabel = (method) => {
|
const getPaymentMethodLabel = (method) => {
|
||||||
const methods = {
|
return PAYMENT_METHODS[method] || method;
|
||||||
'cash': 'Efectivo',
|
|
||||||
'credit_card': 'Tarjeta de Crédito',
|
|
||||||
'debit_card': 'Tarjeta de Débito'
|
|
||||||
};
|
|
||||||
return methods[method] || method;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const getStatusColor = (status) => {
|
const getStatusColor = (status) => {
|
||||||
|
|||||||
@ -8,11 +8,36 @@ const cashRegisterService = {
|
|||||||
* Helper para extraer datos del registro de diferentes formatos de respuesta
|
* Helper para extraer datos del registro de diferentes formatos de respuesta
|
||||||
*/
|
*/
|
||||||
extractRegisterData(response) {
|
extractRegisterData(response) {
|
||||||
return response.register ||
|
const register = response.register ||
|
||||||
response.cashRegister ||
|
response.cashRegister ||
|
||||||
response.cash_register ||
|
response.cash_register ||
|
||||||
response.model ||
|
response.model ||
|
||||||
response;
|
response;
|
||||||
|
|
||||||
|
// Mapear campos del backend a nombres esperados por el frontend
|
||||||
|
if (response.total_sales !== undefined) {
|
||||||
|
register.total_sales = response.total_sales;
|
||||||
|
}
|
||||||
|
if (response.total_cash !== undefined) {
|
||||||
|
register.cash_sales = response.total_cash;
|
||||||
|
}
|
||||||
|
if (response.sales_count !== undefined) {
|
||||||
|
register.transaction_count = response.sales_count;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calcular card_sales sumando credit_card + debit_card
|
||||||
|
const creditCard = parseFloat(response.total_credit_card || 0);
|
||||||
|
const debitCard = parseFloat(response.total_debit_card || 0);
|
||||||
|
if (creditCard > 0 || debitCard > 0) {
|
||||||
|
register.card_sales = creditCard + debitCard;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mantener payment_summary si existe
|
||||||
|
if (response.payment_summary) {
|
||||||
|
register.payment_summary = response.payment_summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
return register;
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -38,32 +63,7 @@ const cashRegisterService = {
|
|||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
api.get(apiURL('cash-registers/current'), {
|
api.get(apiURL('cash-registers/current'), {
|
||||||
onSuccess: (response) => {
|
onSuccess: (response) => {
|
||||||
const register = this.extractRegisterData(response);
|
resolve(this.extractRegisterData(response));
|
||||||
|
|
||||||
// Mapear campos del backend a nombres esperados por el frontend
|
|
||||||
if (response.total_sales !== undefined) {
|
|
||||||
register.total_sales = response.total_sales;
|
|
||||||
}
|
|
||||||
if (response.total_cash !== undefined) {
|
|
||||||
register.cash_sales = response.total_cash;
|
|
||||||
}
|
|
||||||
if (response.sales_count !== undefined) {
|
|
||||||
register.transaction_count = response.sales_count;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calcular card_sales sumando credit_card + debit_card
|
|
||||||
const creditCard = parseFloat(response.total_credit_card || 0);
|
|
||||||
const debitCard = parseFloat(response.total_debit_card || 0);
|
|
||||||
if (creditCard > 0 || debitCard > 0) {
|
|
||||||
register.card_sales = creditCard + debitCard;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mantener payment_summary si existe
|
|
||||||
if (response.payment_summary) {
|
|
||||||
register.payment_summary = response.payment_summary;
|
|
||||||
}
|
|
||||||
|
|
||||||
resolve(register);
|
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
// Si es 404, significa que no hay caja abierta (no es un error)
|
// Si es 404, significa que no hay caja abierta (no es un error)
|
||||||
@ -102,7 +102,34 @@ const cashRegisterService = {
|
|||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
api.get(apiURL('cash-registers'), {
|
api.get(apiURL('cash-registers'), {
|
||||||
params: filters,
|
params: filters,
|
||||||
onSuccess: (response) => resolve(response.registers || response.cashRegisters || response.cash_registers || response),
|
onSuccess: (response) => {
|
||||||
|
const registers = response.registers || response.cashRegisters || response.cash_registers || response;
|
||||||
|
|
||||||
|
// Mapear cada registro en el historial
|
||||||
|
if (registers.data && Array.isArray(registers.data)) {
|
||||||
|
registers.data = registers.data.map(reg => this.extractRegisterData({
|
||||||
|
register: reg,
|
||||||
|
sales_count: reg.sales_count,
|
||||||
|
total_sales: reg.total_sales,
|
||||||
|
total_cash: reg.total_cash,
|
||||||
|
total_credit_card: reg.total_credit_card,
|
||||||
|
total_debit_card: reg.total_debit_card,
|
||||||
|
payment_summary: reg.payment_summary
|
||||||
|
}));
|
||||||
|
} else if (Array.isArray(registers)) {
|
||||||
|
return resolve(registers.map(reg => this.extractRegisterData({
|
||||||
|
register: reg,
|
||||||
|
sales_count: reg.sales_count,
|
||||||
|
total_sales: reg.total_sales,
|
||||||
|
total_cash: reg.total_cash,
|
||||||
|
total_credit_card: reg.total_credit_card,
|
||||||
|
total_debit_card: reg.total_debit_card,
|
||||||
|
payment_summary: reg.payment_summary
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve(registers);
|
||||||
|
},
|
||||||
onError: reject
|
onError: reject
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -33,7 +33,8 @@ const salesService = {
|
|||||||
api.get(apiURL('sales'), {
|
api.get(apiURL('sales'), {
|
||||||
params: filters,
|
params: filters,
|
||||||
onSuccess: (response) => {
|
onSuccess: (response) => {
|
||||||
resolve(response.sales);
|
// Soportar ambos formatos: 'sales' o 'models'
|
||||||
|
resolve(response.sales || response.models);
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
reject(error);
|
reject(error);
|
||||||
|
|||||||
@ -1,15 +1,10 @@
|
|||||||
import jsPDF from 'jspdf';
|
import jsPDF from 'jspdf';
|
||||||
|
import { formatMoney, PAYMENT_METHODS } from '@/utils/formatters';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Servicio para generar tickets de venta en formato PDF
|
* Servicio para generar tickets de venta en formato PDF
|
||||||
*/
|
*/
|
||||||
const ticketService = {
|
const ticketService = {
|
||||||
/**
|
|
||||||
* Helper para formatear moneda
|
|
||||||
*/
|
|
||||||
formatMoney(amount) {
|
|
||||||
return parseFloat(amount || 0).toFixed(2);
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Detectar ubicación del usuario (ciudad y estado)
|
* Detectar ubicación del usuario (ciudad y estado)
|
||||||
@ -130,12 +125,7 @@ const ticketService = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Método de pago
|
// Método de pago
|
||||||
const paymentMethods = {
|
const paymentLabel = PAYMENT_METHODS[saleData.payment_method] || saleData.payment_method;
|
||||||
cash: 'Efectivo',
|
|
||||||
credit_card: 'Tarjeta de Crédito',
|
|
||||||
debit_card: 'Tarjeta de Débito'
|
|
||||||
};
|
|
||||||
const paymentLabel = paymentMethods[saleData.payment_method] || saleData.payment_method;
|
|
||||||
doc.text(`Pago: ${paymentLabel}`, leftMargin, yPosition);
|
doc.text(`Pago: ${paymentLabel}`, leftMargin, yPosition);
|
||||||
yPosition += 6;
|
yPosition += 6;
|
||||||
|
|
||||||
@ -190,7 +180,7 @@ const ticketService = {
|
|||||||
|
|
||||||
// Cantidad y Precio unitario
|
// Cantidad y Precio unitario
|
||||||
const quantity = item.quantity || 1;
|
const quantity = item.quantity || 1;
|
||||||
const unitPrice = this.formatMoney(item.unit_price);
|
const unitPrice = formatMoney(item.unit_price);
|
||||||
|
|
||||||
doc.setFontSize(8);
|
doc.setFontSize(8);
|
||||||
doc.setFont('helvetica', 'normal');
|
doc.setFont('helvetica', 'normal');
|
||||||
@ -217,12 +207,12 @@ const ticketService = {
|
|||||||
|
|
||||||
// Subtotal
|
// Subtotal
|
||||||
doc.text('Subtotal:', leftMargin, yPosition);
|
doc.text('Subtotal:', leftMargin, yPosition);
|
||||||
doc.text(`$${this.formatMoney(saleData.subtotal)}`, rightMargin, yPosition, { align: 'right' });
|
doc.text(`$${formatMoney(saleData.subtotal)}`, rightMargin, yPosition, { align: 'right' });
|
||||||
yPosition += 5;
|
yPosition += 5;
|
||||||
|
|
||||||
// IVA
|
// IVA
|
||||||
doc.text('IVA (16%):', leftMargin, yPosition);
|
doc.text('IVA (16%):', leftMargin, yPosition);
|
||||||
doc.text(`$${this.formatMoney(saleData.tax)}`, rightMargin, yPosition, { align: 'right' });
|
doc.text(`$${formatMoney(saleData.tax)}`, rightMargin, yPosition, { align: 'right' });
|
||||||
yPosition += 6;
|
yPosition += 6;
|
||||||
|
|
||||||
// Línea antes del total
|
// Línea antes del total
|
||||||
@ -235,7 +225,7 @@ const ticketService = {
|
|||||||
doc.setFont('helvetica', 'bold');
|
doc.setFont('helvetica', 'bold');
|
||||||
doc.setTextColor(...blackColor);
|
doc.setTextColor(...blackColor);
|
||||||
doc.text('TOTAL:', leftMargin, yPosition);
|
doc.text('TOTAL:', leftMargin, yPosition);
|
||||||
doc.text(`$${this.formatMoney(saleData.total)}`, rightMargin, yPosition, { align: 'right' });
|
doc.text(`$${formatMoney(saleData.total)}`, rightMargin, yPosition, { align: 'right' });
|
||||||
yPosition += 8;
|
yPosition += 8;
|
||||||
|
|
||||||
// Línea decorativa doble
|
// Línea decorativa doble
|
||||||
@ -361,23 +351,23 @@ const ticketService = {
|
|||||||
doc.setTextColor(...blackColor);
|
doc.setTextColor(...blackColor);
|
||||||
|
|
||||||
doc.text('Efectivo Inicial:', leftMargin, yPosition);
|
doc.text('Efectivo Inicial:', leftMargin, yPosition);
|
||||||
doc.text(`$${this.formatMoney(cashRegisterData.initial_cash)}`, rightMargin, yPosition, { align: 'right' });
|
doc.text(`$${formatMoney(cashRegisterData.initial_cash)}`, rightMargin, yPosition, { align: 'right' });
|
||||||
yPosition += 5;
|
yPosition += 5;
|
||||||
|
|
||||||
doc.text('Ventas Totales:', leftMargin, yPosition);
|
doc.text('Ventas Totales:', leftMargin, yPosition);
|
||||||
doc.text(`$${this.formatMoney(cashRegisterData.total_sales)}`, rightMargin, yPosition, { align: 'right' });
|
doc.text(`$${formatMoney(cashRegisterData.total_sales)}`, rightMargin, yPosition, { align: 'right' });
|
||||||
yPosition += 5;
|
yPosition += 5;
|
||||||
|
|
||||||
doc.text('Efectivo Final:', leftMargin, yPosition);
|
doc.text('Efectivo Final:', leftMargin, yPosition);
|
||||||
doc.text(`$${this.formatMoney(cashRegisterData.final_cash)}`, rightMargin, yPosition, { align: 'right' });
|
doc.text(`$${formatMoney(cashRegisterData.final_cash)}`, rightMargin, yPosition, { align: 'right' });
|
||||||
yPosition += 6;
|
yPosition += 6;
|
||||||
|
|
||||||
// Total
|
// Total
|
||||||
doc.setFont('helvetica', 'bold');
|
doc.setFont('helvetica', 'bold');
|
||||||
doc.text('Diferencia:', leftMargin, yPosition);
|
doc.text('Diferencia:', leftMargin, yPosition);
|
||||||
const difference = parseFloat(cashRegisterData.final_cash || 0) -
|
const difference = parseFloat(cashRegisterData.final_cash || 0) -
|
||||||
(parseFloat(cashRegisterData.initial_cash || 0) + parseFloat(cashRegisterData.cash_sales || 0));
|
(parseFloat(cashRegisterData.initial_cash || 0) + parseFloat(cashRegisterData.cash_sales || 0));
|
||||||
doc.text(`$${this.formatMoney(difference)}`, rightMargin, yPosition, { align: 'right' });
|
doc.text(`$${formatMoney(difference)}`, rightMargin, yPosition, { align: 'right' });
|
||||||
yPosition += 8;
|
yPosition += 8;
|
||||||
|
|
||||||
// Footer
|
// Footer
|
||||||
|
|||||||
@ -1,6 +1,19 @@
|
|||||||
import { defineStore } from 'pinia';
|
import { defineStore } from 'pinia';
|
||||||
import cashRegisterService from '@Services/cashRegisterService';
|
import cashRegisterService from '@Services/cashRegisterService';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper para extraer mensaje de error de diferentes formatos
|
||||||
|
* @param {Error|Object} error - Error object
|
||||||
|
* @param {String} defaultMessage - Mensaje por defecto
|
||||||
|
* @returns {String}
|
||||||
|
*/
|
||||||
|
const extractErrorMessage = (error, defaultMessage) => {
|
||||||
|
return error?.response?.data?.message ||
|
||||||
|
error?.message ||
|
||||||
|
error?.error ||
|
||||||
|
defaultMessage;
|
||||||
|
};
|
||||||
|
|
||||||
const useCashRegister = defineStore('cashRegister', {
|
const useCashRegister = defineStore('cashRegister', {
|
||||||
state: () => ({
|
state: () => ({
|
||||||
currentRegister: null, // Caja actualmente abierta
|
currentRegister: null, // Caja actualmente abierta
|
||||||
|
|||||||
149
src/utils/formatters.js
Normal file
149
src/utils/formatters.js
Normal file
@ -0,0 +1,149 @@
|
|||||||
|
/**
|
||||||
|
* Utilidades para formateo de datos
|
||||||
|
* Centraliza funciones de formato comunes usadas en todo el proyecto
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formatea un número como moneda en pesos mexicanos
|
||||||
|
* @param {Number|String} amount - Cantidad a formatear
|
||||||
|
* @returns {String} - Cantidad formateada con símbolo de moneda
|
||||||
|
* @example
|
||||||
|
* formatCurrency(1234.56) // "$1,234.56"
|
||||||
|
* formatCurrency("1234.56") // "$1,234.56"
|
||||||
|
* formatCurrency(null) // "$0.00"
|
||||||
|
*/
|
||||||
|
export const formatCurrency = (amount) => {
|
||||||
|
return new Intl.NumberFormat('es-MX', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: 'MXN'
|
||||||
|
}).format(amount || 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formatea un número como moneda simple (solo el número con 2 decimales)
|
||||||
|
* @param {Number|String} amount - Cantidad a formatear
|
||||||
|
* @returns {String} - Cantidad formateada sin símbolo de moneda
|
||||||
|
* @example
|
||||||
|
* formatMoney(1234.56) // "1234.56"
|
||||||
|
* formatMoney("1234.567") // "1234.57"
|
||||||
|
* formatMoney(null) // "0.00"
|
||||||
|
*/
|
||||||
|
export const formatMoney = (amount) => {
|
||||||
|
return parseFloat(amount || 0).toFixed(2);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parsea un valor a número flotante de forma segura
|
||||||
|
* @param {Number|String} value - Valor a parsear
|
||||||
|
* @param {Number} defaultValue - Valor por defecto si el parseo falla
|
||||||
|
* @returns {Number} - Número parseado o valor por defecto
|
||||||
|
* @example
|
||||||
|
* safeParseFloat("123.45") // 123.45
|
||||||
|
* safeParseFloat(null) // 0
|
||||||
|
* safeParseFloat("invalid", 10) // 10
|
||||||
|
*/
|
||||||
|
export const safeParseFloat = (value, defaultValue = 0) => {
|
||||||
|
const parsed = parseFloat(value);
|
||||||
|
return isNaN(parsed) ? defaultValue : parsed;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formatea una fecha en formato legible en español
|
||||||
|
* @param {String|Date} dateString - Fecha a formatear
|
||||||
|
* @param {Object} options - Opciones de formato
|
||||||
|
* @returns {String} - Fecha formateada
|
||||||
|
* @example
|
||||||
|
* formatDate("2024-01-15T10:30:00") // "15 de enero de 2024"
|
||||||
|
* formatDate("2024-01-15T10:30:00", { includeTime: true }) // "15 de enero de 2024, 10:30"
|
||||||
|
*/
|
||||||
|
export const formatDate = (dateString, options = {}) => {
|
||||||
|
if (!dateString) return '-';
|
||||||
|
|
||||||
|
const date = new Date(dateString);
|
||||||
|
const {
|
||||||
|
includeTime = false,
|
||||||
|
shortMonth = false,
|
||||||
|
shortDate = false
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
if (shortDate) {
|
||||||
|
// Formato corto: 15/01/2024
|
||||||
|
return new Intl.DateTimeFormat('es-MX', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit'
|
||||||
|
}).format(date);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dateOptions = {
|
||||||
|
year: 'numeric',
|
||||||
|
month: shortMonth ? '2-digit' : 'long',
|
||||||
|
day: 'numeric'
|
||||||
|
};
|
||||||
|
|
||||||
|
if (includeTime) {
|
||||||
|
dateOptions.hour = '2-digit';
|
||||||
|
dateOptions.minute = '2-digit';
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Intl.DateTimeFormat('es-MX', dateOptions).format(date);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formatea solo la hora de una fecha
|
||||||
|
* @param {String|Date} dateString - Fecha de la cual extraer la hora
|
||||||
|
* @param {Boolean} includeSeconds - Si incluir segundos
|
||||||
|
* @returns {String} - Hora formateada
|
||||||
|
* @example
|
||||||
|
* formatTime("2024-01-15T10:30:45") // "10:30"
|
||||||
|
* formatTime("2024-01-15T10:30:45", true) // "10:30:45"
|
||||||
|
*/
|
||||||
|
export const formatTime = (dateString, includeSeconds = false) => {
|
||||||
|
if (!dateString) return '-';
|
||||||
|
|
||||||
|
const date = new Date(dateString);
|
||||||
|
const options = {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
};
|
||||||
|
|
||||||
|
if (includeSeconds) {
|
||||||
|
options.second = '2-digit';
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Intl.DateTimeFormat('es-MX', options).format(date);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formatea un número como porcentaje
|
||||||
|
* @param {Number|String} value - Valor a formatear
|
||||||
|
* @param {Number} decimals - Número de decimales
|
||||||
|
* @returns {String} - Porcentaje formateado
|
||||||
|
* @example
|
||||||
|
* formatPercent(0.16) // "16.00%"
|
||||||
|
* formatPercent(16, 0) // "16%"
|
||||||
|
*/
|
||||||
|
export const formatPercent = (value, decimals = 2) => {
|
||||||
|
const number = parseFloat(value || 0);
|
||||||
|
return `${number.toFixed(decimals)}%`;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mapeo de métodos de pago a sus etiquetas en español
|
||||||
|
*/
|
||||||
|
export const PAYMENT_METHODS = {
|
||||||
|
cash: 'Efectivo',
|
||||||
|
credit_card: 'Tarjeta de Crédito',
|
||||||
|
debit_card: 'Tarjeta de Débito',
|
||||||
|
transfer: 'Transferencia',
|
||||||
|
other: 'Otro'
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtener la etiqueta de un método de pago
|
||||||
|
* @param {String} method - Código del método de pago
|
||||||
|
* @returns {String} - Etiqueta en español
|
||||||
|
*/
|
||||||
|
export const getPaymentMethodLabel = (method) => {
|
||||||
|
return PAYMENT_METHODS[method] || method;
|
||||||
|
};
|
||||||
Loading…
x
Reference in New Issue
Block a user