feat: agregar campos fiscales y funcionalidad de descarga de reportes en Excel

This commit is contained in:
Juan Felipe Zapata Moreno 2026-01-30 14:17:55 -06:00
parent 992ecb07b7
commit 8210d7dd2f
8 changed files with 600 additions and 50 deletions

View File

@ -25,12 +25,15 @@ const props = defineProps({
const emit = defineEmits(['close', 'confirm']); const emit = defineEmits(['close', 'confirm']);
/** Estado */ /** Estado */
let debounceTimer = null;
const selectedMethod = ref('cash'); const selectedMethod = ref('cash');
const cashReceived = ref(0); const cashReceived = ref(0);
const clientNumber = ref(''); const clientNumber = ref('');
const selectedClient = ref(null); const selectedClient = ref(null);
const searchingClient = ref(false); const searchingClient = ref(false);
const clientNotFound = ref(false); const clientNotFound = ref(false);
const clientSuggestions = ref([]);
const showClientSuggestions = ref(false);
/** Computados */ /** Computados */
const formattedSubtotal = computed(() => { const formattedSubtotal = computed(() => {
@ -116,10 +119,25 @@ const paymentMethods = [
]; ];
/** Métodos de búsqueda de cliente */ /** Métodos de búsqueda de cliente */
const onClientInput = () =>{
clientNotFound.value = false;
if(!clientNumber.value || clientNumber.value.trim().length < 2) {
clientSuggestions.value = [];
showClientSuggestions.value = false;
return;
}
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
showClientSuggestions.value = true;
searchClient();
}, 300);
}
const searchClient = () => { const searchClient = () => {
if (!clientNumber.value || clientNumber.value.trim() === '') { if (!clientNumber.value || clientNumber.value.trim() === '') {
selectedClient.value = null; clientSuggestions.value = [];
clientNotFound.value = false; showClientSuggestions.value = false;
return; return;
} }
@ -131,22 +149,23 @@ const searchClient = () => {
api.get(apiURL(`clients?${urlParams}`), { api.get(apiURL(`clients?${urlParams}`), {
onSuccess: (data) => { onSuccess: (data) => {
if (data.clients && data.clients.data.length > 0) { if (data.clients && data.clients.data.length > 0) {
const client = data.clients.data[0]; clientSuggestions.value = data.clients.data;
selectedClient.value = client; showClientSuggestions.value = true;
clientNotFound.value = false;
window.Notify.success(`Cliente ${client.name} encontrado`);
} else { } else {
selectedClient.value = null; clientSuggestions.value = [];
showClientSuggestions.value = false;
clientNotFound.value = true; clientNotFound.value = true;
} }
}, },
onFail: (data) => { onFail: (data) => {
selectedClient.value = null; clientSuggestions.value = [];
showClientSuggestions.value = false;
clientNotFound.value = true; clientNotFound.value = true;
window.Notify.error(data.message || 'Error al buscar cliente'); window.Notify.error(data.message || 'Error al buscar cliente');
}, },
onError: () => { onError: () => {
selectedClient.value = null; clientSuggestions.value = [];
showClientSuggestions.value = false;
clientNotFound.value = true; clientNotFound.value = true;
}, },
onFinish: () => { onFinish: () => {
@ -155,10 +174,22 @@ const searchClient = () => {
}); });
}; };
const selectClient = (client) => {
selectedClient.value = client;
clientNumber.value = '';
clientSuggestions.value = [];
showClientSuggestions.value = false;
clientNotFound.value = false;
window.Notify.success(`Cliente ${client.name} seleccionado`);
};
const clearClient = () => { const clearClient = () => {
clientNumber.value = ''; clientNumber.value = '';
selectedClient.value = null; selectedClient.value = null;
clientNotFound.value = false; clientNotFound.value = false;
clientSuggestions.value = [];
showClientSuggestions.value = false;
}; };
@ -178,6 +209,8 @@ watch(() => props.show, (isShown) => {
clientNumber.value = ''; clientNumber.value = '';
selectedClient.value = null; selectedClient.value = null;
clientNotFound.value = false; clientNotFound.value = false;
clientSuggestions.value = [];
showClientSuggestions.value = false;
} }
}); });
@ -358,22 +391,51 @@ const formattedEstimatedTotal = computed(() => {
<div class="relative"> <div class="relative">
<input <input
v-model="clientNumber" v-model="clientNumber"
@keyup.enter="searchClient" @keyup.enter="onClientInput"
@focus="clientSuggestions.length > 0 && (showClientSuggestions = true)"
type="text" type="text"
placeholder="Ingresa el código de cliente (ej: CLI-0001)" placeholder="Ingresa el código de cliente (ej: MOGF780404S36)"
class="w-full px-4 py-3 pr-24 border-2 border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder-gray-400" class="w-full px-4 py-3 pr-24 border-2 border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder-gray-400"
:class="{ :class="{
'border-red-500 focus:ring-red-500 focus:border-red-500': clientNotFound 'border-red-500 focus:ring-red-500 focus:border-red-500': clientNotFound
}" }"
:disabled="searchingClient" :disabled="searchingClient"
/> />
<button <div v-if="searchingClient" class="absolute right-3 top-1/2 -translate-y-1/2">
@click="searchClient" <GoogleIcon name="hourglass_empty" class="text-xl text-gray-400 animate-spin" />
:disabled="searchingClient || !clientNumber" </div>
class="absolute right-2 top-1/2 -translate-y-1/2 px-4 py-1.5 bg-indigo-600 hover:bg-indigo-700 text-white text-sm font-semibold rounded-md transition-colors disabled:opacity-50 disabled:cursor-not-allowed" <div v-else-if="clientNumber" class="absolute right-3 top-1/2 -translate-y-1/2">
<GoogleIcon name="search" class="text-xl text-gray-400" />
</div>
<!-- Dropdown de sugerencias -->
<div
v-if="showClientSuggestions && clientSuggestions.length > 0"
class="absolute z-50 w-full mt-1 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg max-h-48 overflow-y-auto"
> >
{{ searchingClient ? 'Buscando...' : 'Buscar' }} <button
</button> v-for="client in clientSuggestions"
:key="client.id"
type="button"
@click="selectClient(client)"
class="w-full flex items-center gap-3 px-4 py-3 hover:bg-indigo-50 dark:hover:bg-indigo-900/20 transition-colors text-left border-b border-gray-100 dark:border-gray-700 last:border-b-0"
>
<div class="w-8 h-8 rounded-full bg-indigo-600 flex items-center justify-center text-white font-bold text-sm shrink-0">
{{ client.name.charAt(0).toUpperCase() }}
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-semibold text-gray-900 dark:text-gray-100 truncate">
{{ client.name }}
</p>
<p class="text-xs text-gray-500 dark:text-gray-400">
{{ client.client_number }}
<span v-if="client.tier" class="ml-1 text-indigo-500">· {{ client.tier.tier_name }}</span>
</p>
</div>
<span v-if="client.tier?.discount_percentage" class="text-xs font-semibold text-green-600 dark:text-green-400 shrink-0">
{{ parseFloat(client.tier.discount_percentage).toFixed(0) }}% dto.
</span>
</button>
</div>
</div> </div>
<!-- Error de cliente no encontrado --> <!-- Error de cliente no encontrado -->

View File

@ -0,0 +1,229 @@
<script setup>
import { onMounted, ref, vModelSelect } from 'vue';
import { useApi, apiURL } from '@Services/Api';
import GoogleIcon from '@Shared/GoogleIcon.vue';
import Modal from '@Holos/Modal.vue';
import PrimaryButton from '@Holos/Button/Primary.vue';
/** Props */
const props = defineProps({
show: {
type: Boolean,
required: true
}
});
/** Emits */
const emit = defineEmits(['close']);
/** Estado */
const startDate = ref('');
const endDate = ref('');
const downloading = ref(false);
const users = ref([]);
const user_id = ref('');
const fetchUsers = () => {
const api = useApi();
api.get(apiURL('admin/users'), {
onSuccess: (data) => {
users.value = data.models?.data
}
});
};
/** Métodos */
const downloadReport = () => {
if (!startDate.value || !endDate.value) {
window.Notify.error('Por favor selecciona ambas fechas');
return;
}
if (new Date(startDate.value) > new Date(endDate.value)) {
window.Notify.error('La fecha inicial debe ser menor a la fecha final');
return;
}
downloading.value = true;
// Construir URL con parámetros
let url = apiURL(`reports/sales/excel?fecha_inicio=${startDate.value}&fecha_fin=${endDate.value}`);
if (user_id.value) {
url += `&user_id=${user_id.value}`;
}
// Hacer petición con axios para descargar archivo
window.axios.get(url, {
responseType: 'blob',
headers: {
'Authorization': `Bearer ${sessionStorage.token}`,
}
})
.then(response => {
// Crear URL del blob
const blob = new Blob([response.data], {
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
});
const downloadUrl = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = downloadUrl;
link.download = `reporte_ventas_${startDate.value}_${endDate.value}.xlsx`;
document.body.appendChild(link);
link.click();
// Limpiar
document.body.removeChild(link);
window.URL.revokeObjectURL(downloadUrl);
window.Notify.success('Reporte descargado exitosamente');
})
.catch(async error => {
if (error.response && error.response.data instanceof Blob) {
const text = await error.response.data.text();
try {
const json = JSON.parse(text);
window.Notify.warning(json.message || 'Error al descargar el reporte');
} catch {
window.Notify.error('Error al descargar el reporte');
}
} else {
window.Notify.error('Error al descargar el reporte');
}
})
.finally(() => {
downloading.value = false;
});
};
const clearDates = () => {
startDate.value = '';
endDate.value = '';
user_id.value = '';
};
const close = () => {
emit('close');
};
onMounted(() => {
fetchUsers();
});
</script>
<template>
<Modal :show="show" max-width="3xl" @close="close">
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-6 relative">
<div class="flex items-center gap-3 mb-6">
<div class="flex items-center justify-center w-10 h-10 rounded-full bg-green-100 dark:bg-green-900/30">
<GoogleIcon name="download" class="text-xl text-green-600 dark:text-green-400" />
</div>
<div>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
Reporte de Ventas
</h3>
<p class="text-sm text-gray-500 dark:text-gray-400">
Genera un archivo Excel con las ventas realizadas
</p>
</div>
<div class="absolute top-4 right-4">
<button
@click="close"
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
</div>
<div class="space-y-4">
<!-- Rango de Fechas -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Fecha Inicial
</label>
<input
v-model="startDate"
type="date"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
:disabled="downloading"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Fecha Final
</label>
<input
v-model="endDate"
type="date"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
:disabled="downloading"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Vendedor
</label>
<select
v-model="user_id"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
:disabled="downloading"
>
<option value="">Todos</option>
<option v-for="user in users" :key="user.id" :value="user.id">
{{ user.name }}
</option>
</select>
</div>
</div>
<!-- Botones -->
<div class="flex items-center gap-3">
<PrimaryButton
@click="downloadReport"
:disabled="downloading || !startDate || !endDate"
class="flex items-center gap-2"
>
<svg v-if="downloading" class="animate-spin h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<GoogleIcon v-else name="download" />
{{ downloading ? 'Generando...' : 'Descargar Excel' }}
</PrimaryButton>
<button
@click="clearDates"
class="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-lg transition-colors"
:disabled="downloading"
>
Limpiar
</button>
</div>
<!-- Nota informativa -->
<div class="mt-4 p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg border border-blue-200 dark:border-blue-800">
<div class="flex gap-2">
<GoogleIcon name="info" class="text-blue-600 dark:text-blue-400 text-xl flex-shrink-0" />
<div class="text-sm text-blue-700 dark:text-blue-300">
<p class="font-medium mb-1">Información del reporte:</p>
<ul class="list-disc list-inside space-y-1">
<li>Incluye ventas realizadas en el rango seleccionado</li>
<li>Muestra los detalles de venta y cliente si tiene asociado uno</li>
</ul>
</div>
</div>
</div>
</div>
</div>
</Modal>
</template>

View File

@ -20,6 +20,10 @@ const form = useForm({
phone: '', phone: '',
address: '', address: '',
rfc: '', rfc: '',
razon_social: '',
regimen_fiscal: '',
cp_fiscal: '',
uso_cfdi: ''
}); });
/** Métodos */ /** Métodos */
@ -132,6 +136,62 @@ const closeModal = () => {
/> />
<FormError :message="form.errors?.rfc" /> <FormError :message="form.errors?.rfc" />
</div> </div>
<!-- RAZÓN SOCIAL -->
<div>
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
RAZÓN SOCIAL
</label>
<FormInput
v-model="form.razon_social"
type="text"
placeholder="Razón Social"
required
/>
<FormError :message="form.errors?.razon_social" />
</div>
<!-- REGIMEN FISCAL-->
<div>
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
REGIMEN FISCAL
</label>
<FormInput
v-model="form.regimen_fiscal"
type="text"
placeholder="Regimen Fiscal"
required
/>
<FormError :message="form.errors?.regimen_fiscal" />
</div>
<!-- CP FISCAL-->
<div>
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
CP FISCAL
</label>
<FormInput
v-model="form.cp_fiscal"
type="text"
placeholder="CP Fiscal"
required
/>
<FormError :message="form.errors?.cp_fiscal" />
</div>
<!-- USO CFDI -->
<div>
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
USO DE CFDI
</label>
<FormInput
v-model="form.uso_cdfi"
type="text"
placeholder="03 - Gastos en general"
required
/>
<FormError :message="form.errors?.uso_cdfi" />
</div>
</div> </div>
<!-- Botones --> <!-- Botones -->

View File

@ -22,6 +22,10 @@ const form = useForm({
phone: '', phone: '',
address: '', address: '',
rfc: '', rfc: '',
razon_social: '',
regimen_fiscal: '',
cp_fiscal: '',
uso_cfdi: ''
}); });
/** Métodos */ /** Métodos */
@ -51,6 +55,10 @@ watch(() => props.client, (newClient) => {
form.phone = newClient.phone || ''; form.phone = newClient.phone || '';
form.address = newClient.address || ''; form.address = newClient.address || '';
form.rfc = newClient.rfc || ''; form.rfc = newClient.rfc || '';
form.razon_social = newClient.razon_social || '';
form.regimen_fiscal = newClient.regimen_fiscal || '';
form.cp_fiscal = newClient.cp_fiscal || '';
form.uso_cfdi = newClient.uso_cfdi || '';
} }
}, { immediate: true }); }, { immediate: true });
</script> </script>
@ -146,6 +154,62 @@ watch(() => props.client, (newClient) => {
/> />
<FormError :message="form.errors?.rfc" /> <FormError :message="form.errors?.rfc" />
</div> </div>
<!-- RAZÓN SOCIAL -->
<div>
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
RAZÓN SOCIAL
</label>
<FormInput
v-model="form.razon_social"
type="text"
placeholder="Razón social del cliente"
required
/>
<FormError :message="form.errors?.razon_social" />
</div>
<!-- REGIMEN FISCAL-->
<div>
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
REGIMEN FISCAL
</label>
<FormInput
v-model="form.regimen_fiscal"
type="text"
placeholder="Régimen fiscal del cliente"
required
/>
<FormError :message="form.errors?.regimen_fiscal" />
</div>
<!-- CP FISCAL-->
<div>
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
CP FISCAL
</label>
<FormInput
v-model="form.cp_fiscal"
type="text"
placeholder="CP fiscal del cliente"
required
/>
<FormError :message="form.errors?.cp_fiscal" />
</div>
<!-- USO CFDI -->
<div>
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
USO CFDI
</label>
<FormInput
v-model="form.uso_cfdi"
type="text"
placeholder="03 - GASTOS EN GENERAL"
required
/>
<FormError :message="form.errors?.uso_cfdi" />
</div>
</div> </div>
<!-- Botones --> <!-- Botones -->

View File

@ -164,6 +164,10 @@ onMounted(() => {
<th class="px-6 py-3 text-center text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">TELEFONO</th> <th class="px-6 py-3 text-center text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">TELEFONO</th>
<th class="px-6 py-3 text-center text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">DIRECCIÓN</th> <th class="px-6 py-3 text-center text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">DIRECCIÓN</th>
<th class="px-6 py-3 text-center text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">RFC</th> <th class="px-6 py-3 text-center text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">RFC</th>
<th class="px-6 py-3 text-center text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">RAZÓN SOCIAL</th>
<th class="px-6 py-3 text-center text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">REGIMEN FISCAL</th>
<th class="px-6 py-3 text-center text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">CP FISCAL</th>
<th class="px-6 py-3 text-center text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">USO CFDI</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> <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>
<template #body="{items}"> <template #body="{items}">
@ -190,6 +194,18 @@ onMounted(() => {
<td class="px-6 py-4 text-center"> <td class="px-6 py-4 text-center">
<p class="text-sm text-gray-700 dark:text-gray-300">{{ client.rfc }}</p> <p class="text-sm text-gray-700 dark:text-gray-300">{{ client.rfc }}</p>
</td> </td>
<td class="px-6 py-4 text-center">
<p class="text-sm text-gray-700 dark:text-gray-300">{{ client.razon_social }}</p>
</td>
<td class="px-6 py-4 text-center">
<p class="text-sm text-gray-700 dark:text-gray-300">{{ client.regimen_fiscal }}</p>
</td>
<td class="px-6 py-4 text-center">
<p class="text-sm text-gray-700 dark:text-gray-300">{{ client.cp_fiscal }}</p>
</td>
<td class="px-6 py-4 text-center">
<p class="text-sm text-gray-700 dark:text-gray-300">{{ client.uso_cfdi }}</p>
</td>
<td class="px-6 py-4 whitespace-nowrap text-center"> <td class="px-6 py-4 whitespace-nowrap text-center">
<div class="flex items-center justify-center gap-2"> <div class="flex items-center justify-center gap-2">
<button <button
@ -227,7 +243,7 @@ onMounted(() => {
</tr> </tr>
</template> </template>
<template #empty> <template #empty>
<td colspan="7" class="table-cell text-center"> <td colspan="11" class="table-cell text-center">
<div class="flex flex-col items-center justify-center py-8 text-gray-500"> <div class="flex flex-col items-center justify-center py-8 text-gray-500">
<GoogleIcon <GoogleIcon
name="person" name="person"

View File

@ -17,6 +17,7 @@ const submitting = ref(false);
const submitted = ref(false); const submitted = ref(false);
const error = ref(null); const error = ref(null);
const saleData = ref(null); const saleData = ref(null);
const clientData = ref(null);
const formErrors = ref({}); const formErrors = ref({});
const form = ref({ const form = ref({
@ -33,6 +34,7 @@ const form = ref({
/** Computed */ /** Computed */
const invoiceNumber = computed(() => route.params.invoiceNumber); const invoiceNumber = computed(() => route.params.invoiceNumber);
const hasClient = computed(() => !!clientData.value);
/** Métodos */ /** Métodos */
const fetchSaleData = async () => { const fetchSaleData = async () => {
@ -59,8 +61,14 @@ const fetchSaleData = async () => {
return; return;
} }
const data = await response.json(); if (response.status === 204) {
saleData.value = data.sale; error.value = 'No se encontraron datos para esta venta.';
return;
}
const result = await response.json();
saleData.value = result.data.sale;
clientData.value = result.data.client || null;
} catch (err) { } catch (err) {
console.error('Error:', err); console.error('Error:', err);
@ -70,10 +78,25 @@ const fetchSaleData = async () => {
} }
}; };
const submitForm = async () => { const submitForm = async () => {
submitting.value = true; submitting.value = true;
formErrors.value = {}; formErrors.value = {};
const payload = hasClient.value
? {
name: clientData.value.name,
email: clientData.value.email,
phone: clientData.value.phone,
address: clientData.value.address,
rfc: clientData.value.rfc,
razon_social: clientData.value.razon_social,
regimen_fiscal: clientData.value.regimen_fiscal,
cp_fiscal: clientData.value.cp_fiscal,
uso_cfdi: clientData.value.uso_cfdi
}
: form.value;
try { try {
const response = await fetch(apiURL(`facturacion/${invoiceNumber.value}`), { const response = await fetch(apiURL(`facturacion/${invoiceNumber.value}`), {
method: 'POST', method: 'POST',
@ -81,9 +104,14 @@ const submitForm = async () => {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'Accept': 'application/json' 'Accept': 'application/json'
}, },
body: JSON.stringify(form.value) body: JSON.stringify(payload)
}); });
if (response.status === 204 || response.headers.get('content-length') === '0') {
submitted.value = true;
return;
}
const data = await response.json(); const data = await response.json();
if (!response.ok) { if (!response.ok) {
@ -130,7 +158,7 @@ onMounted(() => {
Solicitud de Factura Solicitud de Factura
</h1> </h1>
<p class="text-gray-600 dark:text-gray-400 mt-2"> <p class="text-gray-600 dark:text-gray-400 mt-2">
Complete sus datos fiscales para generar su factura {{ hasClient ? 'Confirme los datos para generar su factura' : 'Complete sus datos fiscales para generar su factura' }}
</p> </p>
</div> </div>
@ -168,7 +196,7 @@ onMounted(() => {
</p> </p>
</div> </div>
<!-- Form --> <!-- Content -->
<div v-else class="space-y-6"> <div v-else class="space-y-6">
<!-- Sale Info Card --> <!-- Sale Info Card -->
<div v-if="saleData" class="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-6"> <div v-if="saleData" class="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-6">
@ -187,7 +215,13 @@ onMounted(() => {
{{ new Date(saleData.created_at).toLocaleDateString('es-MX') }} {{ new Date(saleData.created_at).toLocaleDateString('es-MX') }}
</span> </span>
</div> </div>
<div class="col-span-2"> <div>
<span class="text-gray-500 dark:text-gray-400">Método de pago:</span>
<span class="ml-2 font-semibold text-gray-900 dark:text-white capitalize">
{{ saleData.payment_method?.replace('_', ' ') }}
</span>
</div>
<div>
<span class="text-gray-500 dark:text-gray-400">Total:</span> <span class="text-gray-500 dark:text-gray-400">Total:</span>
<span class="ml-2 font-bold text-lg text-green-600 dark:text-green-400"> <span class="ml-2 font-bold text-lg text-green-600 dark:text-green-400">
{{ formatCurrency(saleData.total) }} {{ formatCurrency(saleData.total) }}
@ -196,8 +230,80 @@ onMounted(() => {
</div> </div>
</div> </div>
<!-- Billing Form --> <!-- Cliente asociado - Solo confirmación -->
<form @submit.prevent="submitForm" class="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-6"> <div v-if="hasClient" class="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-6">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-4 flex items-center gap-2">
<GoogleIcon name="person" class="text-xl" />
Datos del Cliente
</h2>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4 text-sm">
<div>
<span class="text-gray-500 dark:text-gray-400">Nombre:</span>
<span class="ml-2 font-semibold text-gray-900 dark:text-white">{{ clientData.name }}</span>
</div>
<div>
<span class="text-gray-500 dark:text-gray-400">RFC:</span>
<span class="ml-2 font-semibold font-mono text-gray-900 dark:text-white">{{ clientData.rfc || 'No registrado' }}</span>
</div>
<div>
<span class="text-gray-500 dark:text-gray-400">Email:</span>
<span class="ml-2 font-semibold text-gray-900 dark:text-white">{{ clientData.email || 'No registrado' }}</span>
</div>
<div>
<span class="text-gray-500 dark:text-gray-400">Teléfono:</span>
<span class="ml-2 font-semibold text-gray-900 dark:text-white">{{ clientData.phone || 'No registrado' }}</span>
</div>
<div>
<span class="text-gray-500 dark:text-gray-400">Razón Social:</span>
<span class="ml-2 font-semibold text-gray-900 dark:text-white">{{ clientData.razon_social || 'No registrado' }}</span>
</div>
<div>
<span class="text-gray-500 dark:text-gray-400">Régimen Fiscal:</span>
<span class="ml-2 font-semibold text-gray-900 dark:text-white">{{ clientData.regimen_fiscal || 'No registrado' }}</span>
</div>
<div>
<span class="text-gray-500 dark:text-gray-400">C.P. Fiscal:</span>
<span class="ml-2 font-semibold text-gray-900 dark:text-white">{{ clientData.cp_fiscal || 'No registrado' }}</span>
</div>
<div>
<span class="text-gray-500 dark:text-gray-400">Uso CFDI:</span>
<span class="ml-2 font-semibold text-gray-900 dark:text-white">{{ clientData.uso_cfdi || 'No registrado' }}</span>
</div>
<div class="sm:col-span-2">
<span class="text-gray-500 dark:text-gray-400">Dirección:</span>
<span class="ml-2 font-semibold text-gray-900 dark:text-white">{{ clientData.address || 'No registrado' }}</span>
</div>
</div>
<!-- Botón de confirmar -->
<div class="mt-6 flex justify-center">
<PrimaryButton
@click="submitForm"
:class="{ 'opacity-25': submitting }"
:disabled="submitting"
>
<template v-if="submitting">
<svg class="animate-spin h-5 w-5 mr-2" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Procesando...
</template>
<template v-else>
<GoogleIcon name="check_circle" class="mr-2" />
Confirmar y Solicitar Factura
</template>
</PrimaryButton>
</div>
<p class="mt-4 text-xs text-gray-500 dark:text-gray-400 text-center">
La factura se generará con los datos mostrados y se enviará al correo del cliente.
</p>
</div>
<!-- Sin cliente - Formulario manual -->
<form v-else @submit.prevent="submitForm" class="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-6">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-6 flex items-center gap-2"> <h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-6 flex items-center gap-2">
<GoogleIcon name="description" class="text-xl" /> <GoogleIcon name="description" class="text-xl" />
Datos de Facturación Datos de Facturación
@ -211,7 +317,6 @@ onMounted(() => {
placeholder="Juan Pérez García" placeholder="Juan Pérez García"
:onError="formErrors.name" :onError="formErrors.name"
/> />
<Input <Input
v-model="form.email" v-model="form.email"
id="email" id="email"
@ -220,7 +325,6 @@ onMounted(() => {
placeholder="correo@ejemplo.com" placeholder="correo@ejemplo.com"
:onError="formErrors.email" :onError="formErrors.email"
/> />
<Input <Input
v-model="form.phone" v-model="form.phone"
id="phone" id="phone"
@ -229,7 +333,6 @@ onMounted(() => {
placeholder="55 1234 5678" placeholder="55 1234 5678"
:onError="formErrors.phone" :onError="formErrors.phone"
/> />
<Input <Input
v-model="form.rfc" v-model="form.rfc"
id="rfc" id="rfc"
@ -238,7 +341,6 @@ onMounted(() => {
maxlength="13" maxlength="13"
:onError="formErrors.rfc" :onError="formErrors.rfc"
/> />
<Input <Input
v-model="form.razon_social" v-model="form.razon_social"
id="razon_social" id="razon_social"
@ -246,7 +348,6 @@ onMounted(() => {
placeholder="Como aparece en la Constancia Fiscal" placeholder="Como aparece en la Constancia Fiscal"
:onError="formErrors.razon_social" :onError="formErrors.razon_social"
/> />
<Input <Input
v-model="form.regimen_fiscal" v-model="form.regimen_fiscal"
id="regimen_fiscal" id="regimen_fiscal"
@ -254,7 +355,6 @@ onMounted(() => {
placeholder="Ej: 601 - General de Ley" placeholder="Ej: 601 - General de Ley"
:onError="formErrors.regimen_fiscal" :onError="formErrors.regimen_fiscal"
/> />
<Input <Input
v-model="form.cp_fiscal" v-model="form.cp_fiscal"
id="cp_fiscal" id="cp_fiscal"
@ -263,7 +363,6 @@ onMounted(() => {
maxlength="5" maxlength="5"
:onError="formErrors.cp_fiscal" :onError="formErrors.cp_fiscal"
/> />
<Input <Input
v-model="form.uso_cfdi" v-model="form.uso_cfdi"
id="uso_cfdi" id="uso_cfdi"
@ -271,7 +370,6 @@ onMounted(() => {
placeholder="Ej: G03 - Gastos en general" placeholder="Ej: G03 - Gastos en general"
:onError="formErrors.uso_cfdi" :onError="formErrors.uso_cfdi"
/> />
<div class="md:col-span-2"> <div class="md:col-span-2">
<Input <Input
v-model="form.address" v-model="form.address"
@ -283,7 +381,6 @@ onMounted(() => {
</div> </div>
</div> </div>
<!-- Submit Button -->
<div class="mt-6 flex justify-center"> <div class="mt-6 flex justify-center">
<PrimaryButton <PrimaryButton
:class="{ 'opacity-25': submitting }" :class="{ 'opacity-25': submitting }"
@ -303,7 +400,6 @@ onMounted(() => {
</PrimaryButton> </PrimaryButton>
</div> </div>
<!-- Info Note -->
<p class="mt-4 text-xs text-gray-500 dark:text-gray-400 text-center"> <p class="mt-4 text-xs text-gray-500 dark:text-gray-400 text-center">
Al enviar este formulario, acepta que sus datos serán utilizados únicamente para la emisión de su factura fiscal. Al enviar este formulario, acepta que sus datos serán utilizados únicamente para la emisión de su factura fiscal.
</p> </p>
@ -317,3 +413,4 @@ onMounted(() => {
</div> </div>
</div> </div>
</template> </template>

View File

@ -8,11 +8,13 @@ import SearcherHead from '@Holos/Searcher.vue';
import Table from '@Holos/Table.vue'; import Table from '@Holos/Table.vue';
import GoogleIcon from '@Shared/GoogleIcon.vue'; import GoogleIcon from '@Shared/GoogleIcon.vue';
import SaleDetailModal from './DetailModal.vue'; import SaleDetailModal from './DetailModal.vue';
import ExcelModal from '@Components/POS/ExcelSale.vue';
/** Estado */ /** Estado */
const models = ref([]); const models = ref([]);
const showDetailModal = ref(false); const showDetailModal = ref(false);
const selectedSale = ref(null); const selectedSale = ref(null);
const showExcelModal = ref(false);
/** Buscador de ventas */ /** Buscador de ventas */
const searcher = useSearcher({ const searcher = useSearcher({
@ -38,6 +40,14 @@ const closeDetailModal = () => {
selectedSale.value = null; selectedSale.value = null;
}; };
const openExcelModal = () => {
showExcelModal.value = true;
};
const closeExcelModal = () => {
showExcelModal.value = false;
};
const handleCancelSale = async (saleId) => { const handleCancelSale = async (saleId) => {
if (!confirm('¿Estás seguro de cancelar esta venta? Se restaurará el stock.')) { if (!confirm('¿Estás seguro de cancelar esta venta? Se restaurará el stock.')) {
return; return;
@ -108,17 +118,14 @@ onMounted(() => {
placeholder="Buscar por folio o cajero..." placeholder="Buscar por folio o cajero..."
@search="(x) => searcher.search(x)" @search="(x) => searcher.search(x)"
> >
<!-- Se puede agregar filtros de fecha aquí si se desea --> <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="openExcelModal"
>
<GoogleIcon name="add" class="text-xl" />
Generar excel
</button>
</SearcherHead> </SearcherHead>
<!-- Modal de Detalle -->
<SaleDetailModal
:show="showDetailModal"
:sale="selectedSale"
@close="closeDetailModal"
@cancel-sale="handleCancelSale"
/>
<div class="pt-2 w-full"> <div class="pt-2 w-full">
<Table <Table
:items="models" :items="models"
@ -215,4 +222,19 @@ onMounted(() => {
</Table> </Table>
</div> </div>
</div> </div>
<!-- Modal de Detalle -->
<SaleDetailModal
:show="showDetailModal"
:sale="selectedSale"
@close="closeDetailModal"
@cancel-sale="handleCancelSale"
/>
<!-- Modal de Excel de Clientes -->
<ExcelModal
:show="showExcelModal"
@close="closeExcelModal"
/>
</template> </template>

View File

@ -12,9 +12,9 @@ const ticketService = {
*/ */
async getUserLocation() { async getUserLocation() {
return { return {
city: import.meta.env.VITE_BUSINESS_CITY || 'Villahermosa', city: import.meta.env.VITE_BUSINESS_CITY,
state: import.meta.env.VITE_BUSINESS_STATE || 'Tabasco', state: import.meta.env.VITE_BUSINESS_STATE,
country: import.meta.env.VITE_BUSINESS_COUNTRY || 'México' country: import.meta.env.VITE_BUSINESS_COUNTRY
}; };
}, },
@ -31,7 +31,7 @@ const ticketService = {
// Detectar ubicación del usuario // Detectar ubicación del usuario
const location = await this.getUserLocation(); const location = await this.getUserLocation();
const businessAddress = `${location.city}, ${location.state}`; const businessAddress = `${location.city}, ${location.state}, ${location.country}`;
const businessPhone = 'Tel: (52) 0000-0000'; const businessPhone = 'Tel: (52) 0000-0000';
// Crear documento PDF - Ticket térmico 80mm de ancho // Crear documento PDF - Ticket térmico 80mm de ancho