FEAT:Modulo de facturación

This commit is contained in:
Rubi Almora 2026-03-24 11:18:28 -06:00
parent 2b7b884794
commit f53d0ff457
7 changed files with 1100 additions and 1 deletions

View File

@ -4,12 +4,20 @@ import { useRouter } from 'vue-router';
import useLoader from '@Stores/Loader'; import useLoader from '@Stores/Loader';
import { hasToken } from '@Services/Api'; import { hasToken } from '@Services/Api';
const PUBLIC_PATHS = ['/factura/'];
/** Definidores */ /** Definidores */
const router = useRouter(); const router = useRouter();
const loader = useLoader(); const loader = useLoader();
/** Ciclos */ /** Ciclos */
onMounted(() => { onMounted(async () => {
await router.isReady();
const currentPath = router.currentRoute.value.path;
if (PUBLIC_PATHS.some(prefix => currentPath.startsWith(prefix))) return;
if(!hasToken()) { if(!hasToken()) {
return router.push({ name: 'auth.index' }) return router.push({ name: 'auth.index' })
} }

View File

@ -59,6 +59,11 @@ onMounted(() => {
name="Entrega de caja" name="Entrega de caja"
to="checkout.index" to="checkout.index"
/> />
<Link
icon="request_quote"
name="Solicitudes de Factura"
to="invoice-request.index"
/>
</Section> </Section>
<Section <Section
v-if="hasPermission('users.index')" v-if="hasPermission('users.index')"

View File

@ -0,0 +1,206 @@
<script setup>
import { ref, onMounted, watch } from 'vue';
import { useSearcher, useApi } from '@Services/Api';
import { apiTo } from './Module';
import Table from '@Holos/Table.vue';
import IconButton from '@Holos/Button/Icon.vue';
import PageHeader from '@Holos/PageHeader.vue';
import ShowModal from './Modal/Show.vue';
import ModalController from '@Controllers/ModalController.js';
/** Controladores */
const Modal = new ModalController();
const api = useApi();
const showModal = ref(Modal.showModal);
const modelModal = ref(Modal.modelModal);
/** Estado */
const models = ref({ data: [], total: 0, current_page: 1, last_page: 1 });
const statusFilter = ref('');
const statusConfig = {
pending: { label: 'Pendiente', cls: 'bg-yellow-100 text-yellow-800 border-yellow-200' },
processing: { label: 'En proceso', cls: 'bg-blue-100 text-blue-800 border-blue-200' },
completed: { label: 'Completada', cls: 'bg-green-100 text-green-800 border-green-200' },
rejected: { label: 'Rechazada', cls: 'bg-red-100 text-red-800 border-red-200' },
};
const statusTabs = [
{ value: '', label: 'Todas' },
{ value: 'pending', label: 'Pendientes' },
{ value: 'processing', label: 'En proceso' },
{ value: 'completed', label: 'Completadas'},
{ value: 'rejected', label: 'Rechazadas' },
];
/** Buscador */
const searcher = useSearcher({
url: apiTo(),
filters: {},
onSuccess: (data) => {
models.value = data.models;
},
});
const doSearch = (page = 1) => {
const filters = { page };
if (statusFilter.value) filters.status = statusFilter.value;
searcher.search('', filters);
};
onMounted(() => doSearch());
watch(statusFilter, () => doSearch(1));
/** Paginación */
const pages = () => {
const pages = [];
for (let i = 1; i <= models.value.last_page; i++) {
pages.push(i);
}
return pages;
};
/** Abrir detalle — carga info completa */
const openDetail = (model) => {
Modal.switchShowModal(model);
api.get(apiTo(model.id), {
onSuccess: (data) => {
Modal.modelModal.value = data.model;
}
});
};
/** Actualiza fila en la lista al recibir cambio del modal */
const handleUpdated = (updatedModel) => {
const idx = models.value.data.findIndex(m => m.id === updatedModel.id);
if (idx > -1) {
models.value.data[idx] = updatedModel;
}
};
const formatDate = (iso) => {
if (!iso) return '—';
return new Date(iso).toLocaleDateString('es-MX', {
year: 'numeric', month: 'short', day: 'numeric',
hour: '2-digit', minute: '2-digit'
});
};
const formatAmount = (amount) => {
if (!amount) return '—';
return new Intl.NumberFormat('es-MX', { style: 'currency', currency: 'MXN' }).format(amount);
};
</script>
<template>
<PageHeader title="Solicitudes de Factura" />
<div class="mx-4 mb-4 space-y-4">
<!-- Filtros de estado -->
<div class="flex flex-wrap gap-2">
<button
v-for="tab in statusTabs"
:key="tab.value"
class="px-4 py-1.5 rounded-full text-sm font-medium border transition-colors"
:class="statusFilter === tab.value
? 'bg-primary text-white border-primary'
: 'bg-white text-gray-600 border-gray-300 hover:border-primary hover:text-primary'"
@click="statusFilter = tab.value"
>
{{ tab.label }}
</button>
</div>
<!-- Tabla -->
<div class="bg-white rounded-lg shadow-md overflow-hidden">
<Table
:items="models"
:processing="searcher.processing"
>
<template #head>
<th class="px-4 py-3 text-left text-sm font-semibold">#</th>
<th class="px-4 py-3 text-left text-sm font-semibold">Ciudadano</th>
<th class="px-4 py-3 text-left text-sm font-semibold">RFC</th>
<th class="px-4 py-3 text-left text-sm font-semibold">Monto pagado</th>
<th class="px-4 py-3 text-left text-sm font-semibold">Estado</th>
<th class="px-4 py-3 text-left text-sm font-semibold">Fecha solicitud</th>
<th class="px-4 py-3 text-center text-sm font-semibold w-20">Acción</th>
</template>
<template #body="{ items }">
<tr
v-for="model in items"
:key="model.id"
class="border-b border-gray-100 hover:bg-gray-50 transition-colors cursor-pointer"
@click="openDetail(model)"
>
<td class="px-4 py-3 text-sm text-gray-500 font-mono">{{ model.id }}</td>
<td class="px-4 py-3 text-sm text-gray-800 font-medium">
{{ model.name || model.payment?.model?.name || '—' }}
</td>
<td class="px-4 py-3 text-sm text-gray-700 font-mono">{{ model.rfc }}</td>
<td class="px-4 py-3 text-sm text-gray-700">
{{ formatAmount(model.payment?.total_amount) }}
</td>
<td class="px-4 py-3">
<span
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium border"
:class="statusConfig[model.status]?.cls ?? 'bg-gray-100 text-gray-700'"
>
{{ statusConfig[model.status]?.label ?? model.status }}
</span>
</td>
<td class="px-4 py-3 text-sm text-gray-600">
{{ formatDate(model.request_at) }}
</td>
<td class="px-4 py-3 flex justify-center" @click.stop>
<IconButton
icon="visibility"
title="Ver detalle"
outline
@click="openDetail(model)"
/>
</td>
</tr>
</template>
<template #empty>
<td colspan="7" class="px-6 py-10 text-center text-gray-400 text-sm">
No hay solicitudes de factura con los filtros seleccionados.
</td>
</template>
</Table>
</div>
<!-- Paginación personalizada -->
<div
v-if="models.last_page > 1"
class="flex justify-end flex-wrap gap-1"
>
<button
v-for="p in pages()"
:key="p"
class="px-3 py-1 text-sm border rounded transition-colors"
:class="models.current_page === p
? 'bg-primary text-white border-primary'
: 'bg-white text-gray-600 border-gray-300 hover:border-primary'"
@click="doSearch(p)"
>
{{ p }}
</button>
</div>
</div>
<!-- Modal de detalle -->
<ShowModal
:show="showModal"
:model="modelModal"
:loading="api.processing"
@close="Modal.switchShowModal()"
@updated="handleUpdated"
/>
</template>

View File

@ -0,0 +1,356 @@
<script setup>
import { ref, watch, computed } from 'vue';
import { useForm, useApi } from '@Services/Api';
import { apiTo } from '../Module';
import DialogModal from '@Holos/DialogModal.vue';
import PrimaryButton from '@Holos/Button/Primary.vue';
import SecondaryButton from '@Holos/Button/Secondary.vue';
import SingleFile from '@Holos/Form/SingleFile.vue';
import Input from '@Holos/Form/Input.vue';
import Textarea from '@Holos/Form/Textarea.vue';
import GoogleIcon from '@Shared/GoogleIcon.vue';
/** Props */
const props = defineProps({
show: Boolean,
model: Object,
loading: Boolean,
});
/** Eventos */
const emit = defineEmits(['close', 'updated']);
/** API para cambio de estado */
const apiStatus = useApi();
/** Formulario de subida CFDI */
const invoiceForm = ref(useForm({
xml_file: null,
pdf_file: null,
folio: '',
uuid: '',
stamped_at: '',
notes: '',
}));
/** Resetear formulario cuando se abre el modal */
watch(() => props.show, (open) => {
if (open) {
invoiceForm.value = useForm({
xml_file: null,
pdf_file: null,
folio: '',
uuid: '',
stamped_at: '',
notes: '',
});
}
});
const statusConfig = {
pending: { label: 'Pendiente', cls: 'bg-yellow-100 text-yellow-800' },
processing: { label: 'En proceso', cls: 'bg-blue-100 text-blue-800' },
completed: { label: 'Completada', cls: 'bg-green-100 text-green-800' },
rejected: { label: 'Rechazada', cls: 'bg-red-100 text-red-800' },
};
const canChangeStatus = computed(() =>
props.model?.status === 'pending' || props.model?.status === 'processing'
);
const canUpload = computed(() =>
props.model?.status !== 'completed' && props.model?.status !== 'rejected'
);
const formatDate = (iso) => {
if (!iso) return '—';
return new Date(iso).toLocaleDateString('es-MX', {
year: 'numeric', month: 'long', day: 'numeric',
hour: '2-digit', minute: '2-digit'
});
};
const formatAmount = (amount) => {
if (!amount) return '—';
return new Intl.NumberFormat('es-MX', { style: 'currency', currency: 'MXN' }).format(amount);
};
/** Cambiar estado */
const changeStatus = (status) => {
apiStatus.put(apiTo(props.model.id, 'status'), {
data: { status },
onSuccess: (data) => {
Notify.success(
status === 'processing'
? 'Solicitud marcada como "En proceso".'
: 'Solicitud rechazada.'
);
emit('updated', data.model);
emit('close');
},
onFail: (data) => {
Notify.warning(data?.message ?? 'No se pudo cambiar el estado.');
}
});
};
/** Subir CFDI */
const uploadInvoice = () => {
if (!invoiceForm.value.xml_file && !invoiceForm.value.pdf_file) {
Notify.warning('Debes adjuntar al menos el archivo XML o el PDF del CFDI.');
return;
}
invoiceForm.value.post(apiTo(props.model.id, 'invoice'), {
onSuccess: (data) => {
Notify.success(data?.message ?? 'Factura subida y enviada al ciudadano.');
emit('updated', data.model);
emit('close');
},
onError: () => {
Notify.error('Error al subir los archivos. Verifica el formulario.');
}
});
};
</script>
<template>
<DialogModal
:show="show"
max-width="2xl"
@close="emit('close')"
>
<template #title>
<div class="flex items-center gap-3 py-1">
<span class="font-bold text-xl">
Solicitud de Factura #{{ model?.id }}
</span>
<span
v-if="model?.status"
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium"
:class="statusConfig[model.status]?.cls"
>
{{ statusConfig[model.status]?.label }}
</span>
</div>
</template>
<template #content>
<!-- Cargando -->
<div v-if="loading" class="flex justify-center py-10">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
</div>
<div v-else-if="model?.id" class="px-4 pt-2 pb-4 space-y-5">
<!-- Datos del ciudadano -->
<section>
<h3 class="text-sm font-semibold text-gray-500 uppercase tracking-wider mb-2 border-b pb-1">
Datos del ciudadano
</h3>
<div class="grid grid-cols-2 gap-x-6 gap-y-2 text-sm">
<div><span class="text-gray-500">RFC:</span> <span class="font-mono font-medium">{{ model.rfc }}</span></div>
<div><span class="text-gray-500">Nombre:</span> {{ model.name || '—' }}</div>
<div><span class="text-gray-500">Email:</span> {{ model.email }}</div>
<div><span class="text-gray-500">Teléfono:</span> {{ model.phone || '—' }}</div>
<div><span class="text-gray-500">Razón social:</span> {{ model.business_name || '—' }}</div>
<div><span class="text-gray-500">C.P. fiscal:</span> {{ model.fiscal_postal_code }}</div>
<div class="col-span-2"><span class="text-gray-500">Dirección:</span> {{ model.address || '—' }}</div>
</div>
</section>
<!-- Datos fiscales -->
<section>
<h3 class="text-sm font-semibold text-gray-500 uppercase tracking-wider mb-2 border-b pb-1">
Datos fiscales
</h3>
<div class="grid grid-cols-1 gap-y-2 text-sm">
<div>
<span class="text-gray-500">Régimen fiscal:</span>
<span v-if="model.fiscal_regime" class="ml-1">
<span class="font-mono font-medium">{{ model.fiscal_regime.code }}</span>
{{ model.fiscal_regime.name }}
</span>
<span v-else> </span>
</div>
<div>
<span class="text-gray-500">Uso del CFDI:</span>
<span v-if="model.cfdi_use" class="ml-1">
<span class="font-mono font-medium">{{ model.cfdi_use.code }}</span>
{{ model.cfdi_use.name }}
</span>
<span v-else> </span>
</div>
</div>
</section>
<!-- Pago relacionado -->
<section>
<h3 class="text-sm font-semibold text-gray-500 uppercase tracking-wider mb-2 border-b pb-1">
Pago relacionado
</h3>
<div class="grid grid-cols-2 gap-x-6 gap-y-2 text-sm">
<div><span class="text-gray-500">Monto:</span> <span class="font-medium text-green-700">{{ formatAmount(model.payment?.total_amount) }}</span></div>
<div><span class="text-gray-500">Pagado el:</span> {{ formatDate(model.payment?.paid_at) }}</div>
<div><span class="text-gray-500">Infractor:</span> {{ model.payment?.model?.name || '—' }}</div>
<div><span class="text-gray-500">Placa:</span> <span class="font-mono">{{ model.payment?.model?.plate || '—' }}</span></div>
<div><span class="text-gray-500">CURP:</span> <span class="font-mono text-xs">{{ model.payment?.model?.curp || '—' }}</span></div>
</div>
<!-- Conceptos de cargo -->
<div v-if="model.payment?.model?.charge_concepts?.length" class="mt-3">
<p class="text-xs font-semibold text-gray-500 mb-1">Conceptos de cargo:</p>
<div class="space-y-1">
<div
v-for="cc in model.payment.model.charge_concepts"
:key="cc.id"
class="flex justify-between items-center bg-gray-50 rounded px-3 py-1.5 text-xs"
>
<div>
<span class="font-medium">{{ cc.name }}</span>
<span class="text-gray-500 ml-1">({{ cc.article }})</span>
</div>
<span class="font-medium text-gray-700">
{{ formatAmount(cc.pivot?.override_amount) }}
</span>
</div>
</div>
</div>
</section>
<!-- Factura ya generada -->
<section v-if="model.invoice">
<h3 class="text-sm font-semibold text-gray-500 uppercase tracking-wider mb-2 border-b pb-1">
Factura generada
</h3>
<div class="grid grid-cols-2 gap-x-6 gap-y-2 text-sm">
<div><span class="text-gray-500">Folio:</span> <span class="font-mono font-medium">{{ model.invoice.folio || '—' }}</span></div>
<div><span class="text-gray-500">Timbrada el:</span> {{ formatDate(model.invoice.stamped_at) }}</div>
<div class="col-span-2">
<span class="text-gray-500">UUID SAT:</span>
<span class="font-mono text-xs ml-1">{{ model.invoice.uuid || '—' }}</span>
</div>
<div v-if="model.invoice.notes" class="col-span-2">
<span class="text-gray-500">Notas:</span> {{ model.invoice.notes }}
</div>
</div>
<div class="flex gap-3 mt-3">
<a
v-if="model.invoice.xml_url"
:href="model.invoice.xml_url"
target="_blank"
class="inline-flex items-center gap-1 text-sm text-blue-600 hover:underline"
>
<GoogleIcon
class="text-base"
name="code"
/>
Descargar XML
</a>
<a
v-if="model.invoice.pdf_url"
:href="model.invoice.pdf_url"
target="_blank"
class="inline-flex items-center gap-1 text-sm text-red-600 hover:underline"
>
<GoogleIcon
class="text-base"
name="picture_as_pdf"
/>
Descargar PDF
</a>
</div>
</section>
<!-- Cambiar estado -->
<section v-if="canChangeStatus">
<h3 class="text-sm font-semibold text-gray-500 uppercase tracking-wider mb-2 border-b pb-1">
Cambiar estado
</h3>
<div class="flex flex-wrap gap-2">
<button
v-if="model.status === 'pending'"
class="inline-flex items-center gap-1 px-4 py-1.5 rounded text-sm font-medium bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50 transition-colors"
:disabled="apiStatus.processing"
@click="changeStatus('processing')"
>
Marcar en proceso
</button>
<button
class="inline-flex items-center gap-1 px-4 py-1.5 rounded text-sm font-medium bg-red-600 text-white hover:bg-red-700 disabled:opacity-50 transition-colors"
:disabled="apiStatus.processing"
@click="changeStatus('rejected')"
>
Rechazar solicitud
</button>
</div>
</section>
<!-- Subir CFDI -->
<section v-if="canUpload">
<h3 class="text-sm font-semibold text-gray-500 uppercase tracking-wider mb-3 border-b pb-1">
Subir CFDI (XML / PDF)
</h3>
<div class="space-y-3">
<div class="grid grid-cols-2 gap-4">
<SingleFile
v-model="invoiceForm.xml_file"
title="Archivo XML"
accept=".xml,text/xml,application/xml"
/>
<SingleFile
v-model="invoiceForm.pdf_file"
title="Archivo PDF"
accept=".pdf,application/pdf"
/>
</div>
<div class="grid grid-cols-2 gap-4">
<Input
v-model="invoiceForm.folio"
id="Folio"
placeholder="Ej. A-0001"
/>
<Input
v-model="invoiceForm.uuid"
id="UUID del timbre"
placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
/>
</div>
<div class="grid grid-cols-2 gap-4">
<Input
v-model="invoiceForm.stamped_at"
id="Fecha de timbrado"
type="datetime-local"
/>
<div></div>
</div>
<Textarea
v-model="invoiceForm.notes"
id="Notas internas"
placeholder="Ej. Generado con PAC Facturama"
/>
<div class="pt-1">
<button
class="inline-flex items-center gap-2 px-5 py-2 rounded text-sm font-medium bg-green-600 text-white hover:bg-green-700 disabled:opacity-50 transition-colors"
:disabled="invoiceForm.processing"
@click="uploadInvoice"
>
<span v-if="invoiceForm.processing">Subiendo</span>
<span v-else>Subir y enviar al ciudadano</span>
</button>
</div>
</div>
</section>
</div>
</template>
<template #footer>
<SecondaryButton @click="emit('close')">
Cerrar
</SecondaryButton>
</template>
</DialogModal>
</template>

View File

@ -0,0 +1,10 @@
import { apiURL } from '@Services/Api';
const apiTo = (id = null, action = '') => {
let path = 'invoice-requests';
if (id !== null) path += `/${id}`;
if (action) path += `/${action}`;
return apiURL(path);
};
export { apiTo };

View File

@ -0,0 +1,499 @@
<script setup>
import { ref, onMounted, watch } from 'vue';
import { useRoute } from 'vue-router';
import axios from 'axios';
import { apiURL } from '@Services/Api';
import GoogleIcon from '@Shared/GoogleIcon.vue';
/** Ruta actual (para leer :qr_token) */
const route = useRoute();
const qrToken = route.params.qr_token;
/** Estado general */
const loading = ref(true);
const notFound = ref(false);
const paymentData = ref(null);
const invoiceRequest = ref(null);
const hasInvoiceReq = ref(false);
const submitted = ref(false);
const submitting = ref(false);
const serverErrors = ref({});
/** Catálogos SAT */
const fiscalRegimes = ref([]);
const cfdiUses = ref([]);
const loadingCfdi = ref(false);
/** Formulario ciudadano */
const form = ref({
rfc: '',
email: '',
phone: '',
name: '',
business_name: '',
fiscal_postal_code: '',
address: '',
fiscal_regimen_id: null,
cfdi_use_id: null,
selectedRegime: null,
selectedCfdiUse: null,
});
/** HTTP sin autenticación */
const publicGet = (path, params = {}) =>
axios.get(apiURL(path), { params, headers: { Accept: 'application/json' } });
const publicPost = (path, data = {}) =>
axios.post(apiURL(path), data, { headers: { 'Content-Type': 'application/json', Accept: 'application/json' } });
/** Al cargar — obtener datos del pago */
onMounted(async () => {
try {
const { data } = await publicGet(`invoice-request/payment/${qrToken}`);
const res = data?.data ?? data;
paymentData.value = res.model;
hasInvoiceReq.value = res.has_invoice_request;
invoiceRequest.value = res.model?.invoice_request ?? null;
// Prellenar RFC si el pago tiene uno registrado
if (res.model?.model?.rfc) {
form.value.rfc = res.model.model.rfc;
}
} catch (err) {
if (err.response?.status === 404) {
notFound.value = true;
}
} finally {
loading.value = false;
}
// Cargar regímenes fiscales en paralelo
loadFiscalRegimes();
});
const loadFiscalRegimes = async () => {
try {
const { data } = await publicGet('sat/fiscal-regimes');
const res = data?.data ?? data;
fiscalRegimes.value = res.models?.data ?? res.models ?? [];
} catch {}
};
/** Al cambiar régimen — obtener usos de CFDI compatibles */
const onRegimeChange = async (regime) => {
form.value.fiscal_regimen_id = regime?.id ?? null;
form.value.cfdi_use_id = null;
form.value.selectedCfdiUse = null;
cfdiUses.value = [];
if (!regime?.code) return;
loadingCfdi.value = true;
try {
const { data } = await publicGet('sat/cfdi-uses', { fiscal_regime: regime.code });
const res = data?.data ?? data;
cfdiUses.value = res.models?.data ?? res.models ?? [];
} catch {
cfdiUses.value = [];
} finally {
loadingCfdi.value = false;
}
};
const onCfdiChange = (use) => {
form.value.cfdi_use_id = use?.id ?? null;
};
/** Validaciones básicas del cliente */
const rfcRegex = /^[A-ZÑ&]{3,4}\d{6}[A-Z0-9]{3}$/i;
const validate = () => {
const errors = {};
const rfc = form.value.rfc.trim().toUpperCase();
if (!rfc) {
errors.rfc = 'El RFC es obligatorio.';
} else if (!rfcRegex.test(rfc)) {
errors.rfc = 'El RFC no tiene un formato válido (ej. XAXX010101000).';
}
if (!form.value.email.trim()) errors.email = 'El correo electrónico es obligatorio.';
if (!form.value.fiscal_regimen_id) errors.fiscal_regimen_id = 'Selecciona un régimen fiscal.';
if (!form.value.cfdi_use_id) errors.cfdi_use_id = 'Selecciona el uso del CFDI.';
if (!form.value.fiscal_postal_code.trim()) {
errors.fiscal_postal_code = 'El código postal fiscal es obligatorio.';
} else if (!/^\d{5}$/.test(form.value.fiscal_postal_code.trim())) {
errors.fiscal_postal_code = 'El código postal debe tener exactamente 5 dígitos.';
}
return errors;
};
/** Enviar solicitud */
const submitRequest = async () => {
serverErrors.value = {};
const clientErrors = validate();
if (Object.keys(clientErrors).length) {
serverErrors.value = clientErrors;
return;
}
submitting.value = true;
try {
const payload = {
qr_token: qrToken,
rfc: form.value.rfc.trim().toUpperCase(),
email: form.value.email.trim(),
fiscal_regimen_id: form.value.fiscal_regimen_id,
cfdi_use_id: form.value.cfdi_use_id,
fiscal_postal_code: form.value.fiscal_postal_code.trim(),
};
if (form.value.phone.trim()) payload.phone = form.value.phone.trim();
if (form.value.name.trim()) payload.name = form.value.name.trim();
if (form.value.business_name.trim()) payload.business_name = form.value.business_name.trim();
if (form.value.address.trim()) payload.address = form.value.address.trim();
await publicPost('invoice-request', payload);
submitted.value = true;
} catch (err) {
const status = err.response?.status;
if (status === 422) {
serverErrors.value = err.response.data?.errors ?? {};
} else if (status === 409) {
hasInvoiceReq.value = true;
invoiceRequest.value = { status: 'pending' };
} else if (status === 404) {
notFound.value = true;
}
} finally {
submitting.value = false;
}
};
const formatAmount = (a) =>
a ? new Intl.NumberFormat('es-MX', { style: 'currency', currency: 'MXN' }).format(a) : '—';
const formatDate = (iso) =>
iso ? new Date(iso).toLocaleDateString('es-MX', { year: 'numeric', month: 'long', day: 'numeric' }) : '—';
</script>
<template>
<div class="min-h-screen bg-gray-100 flex flex-col">
<!-- Encabezado -->
<header class="bg-[#621134] text-white py-4 px-6 shadow">
<div class="max-w-2xl mx-auto flex items-center gap-3">
<GoogleIcon
class="text-3xl text-white"
name="receipt_long"
/>
<div>
<p class="text-xs font-light opacity-80">H. Ayuntamiento de Comalcalco</p>
<h1 class="text-lg font-bold leading-tight">Solicitud de Factura Electrónica</h1>
</div>
</div>
</header>
<!-- Contenido principal -->
<main class="flex-1 py-8 px-4">
<div class="max-w-2xl mx-auto space-y-5">
<!-- Cargando -->
<div v-if="loading" class="bg-white rounded-xl shadow p-10 flex justify-center">
<div class="animate-spin rounded-full h-10 w-10 border-4 border-[#621134] border-t-transparent"></div>
</div>
<!-- QR inválido / no encontrado -->
<div v-else-if="notFound" class="bg-white rounded-xl shadow p-8 text-center space-y-3">
<span class="material-symbols-outlined text-5xl text-red-400">qr_code_scanner</span>
<h2 class="text-lg font-bold text-gray-700">Código QR no válido</h2>
<p class="text-gray-500 text-sm">
El código QR no es válido o ya expiró. Si crees que es un error, comunícate con el
departamento de Tránsito Municipal.
</p>
</div>
<!-- Solicitud enviada exitosamente -->
<div v-else-if="submitted" class="bg-white rounded-xl shadow p-8 text-center space-y-3">
<GoogleIcon
class="text-5xl text-green-500"
name="check_circle"
/>
<h2 class="text-lg font-bold text-gray-700">¡Solicitud enviada!</h2>
<p class="text-gray-500 text-sm">
Tu solicitud de factura fue recibida correctamente. Te notificaremos cuando esté lista.
</p>
</div>
<!-- Estado de solicitud existente -->
<template v-else-if="hasInvoiceReq && invoiceRequest">
<!-- Datos del pago -->
<div v-if="paymentData" class="bg-white rounded-xl shadow p-5">
<h2 class="text-sm font-semibold text-gray-500 uppercase tracking-wider mb-3">
Pago de referencia
</h2>
<div class="grid grid-cols-2 gap-2 text-sm">
<div><span class="text-gray-500">Titular:</span> <span class="font-medium">{{ paymentData.model?.name }}</span></div>
<div><span class="text-gray-500">Placa:</span> <span class="font-mono">{{ paymentData.model?.plate }}</span></div>
<div><span class="text-gray-500">Monto:</span> <span class="font-semibold text-green-700">{{ formatAmount(paymentData.total_amount) }}</span></div>
<div><span class="text-gray-500">Pagado el:</span> {{ formatDate(paymentData.paid_at) }}</div>
</div>
</div>
<!-- Mensaje de estado -->
<div class="bg-white rounded-xl shadow p-6 text-center space-y-2">
<template v-if="invoiceRequest.status === 'pending'">
<GoogleIcon
class="text-4xl text-yellow-500"
name="hourglass_empty"
/>
<h2 class="text-base font-bold text-gray-700">Solicitud en espera</h2>
<p class="text-gray-500 text-sm">
Tu solicitud fue recibida y está en espera de procesarse.
</p>
</template>
<template v-else-if="invoiceRequest.status === 'processing'">
<GoogleIcon
class="text-4xl text-blue-500"
name="sync"
/>
<h2 class="text-base font-bold text-gray-700">En proceso</h2>
<p class="text-gray-500 text-sm">
Tu factura está siendo generada, pronto te notificaremos.
</p>
</template>
<template v-else-if="invoiceRequest.status === 'completed'">
<GoogleIcon
class="text-4xl text-green-500"
name="task_alt"
/>
<h2 class="text-base font-bold text-gray-700">Factura enviada</h2>
<p class="text-gray-500 text-sm">
Tu factura electrónica fue generada y enviada a tu WhatsApp. Revisa tus mensajes.
</p>
</template>
<template v-else-if="invoiceRequest.status === 'rejected'">
<GoogleIcon
class="text-4xl text-red-400"
name="cancel"
/>
<h2 class="text-base font-bold text-gray-700">Solicitud no procesada</h2>
<p class="text-gray-500 text-sm">
Tu solicitud no pudo procesarse. Comunícate con el departamento de Tránsito Municipal.
</p>
</template>
</div>
</template>
<!-- Formulario ciudadano -->
<template v-else-if="paymentData">
<!-- Datos del pago -->
<div class="bg-white rounded-xl shadow p-5">
<h2 class="text-sm font-semibold text-gray-500 uppercase tracking-wider mb-3">
Pago de referencia
</h2>
<div class="grid grid-cols-2 gap-2 text-sm">
<div><span class="text-gray-500">Titular:</span> <span class="font-medium">{{ paymentData.model?.name }}</span></div>
<div><span class="text-gray-500">Placa:</span> <span class="font-mono">{{ paymentData.model?.plate }}</span></div>
<div><span class="text-gray-500">Monto:</span> <span class="font-semibold text-green-700">{{ formatAmount(paymentData.total_amount) }}</span></div>
<div><span class="text-gray-500">Pagado el:</span> {{ formatDate(paymentData.paid_at) }}</div>
</div>
<!-- Conceptos -->
<div v-if="paymentData.model?.charge_concepts?.length" class="mt-3 pt-3 border-t">
<p class="text-xs text-gray-500 mb-1">Conceptos cobrados:</p>
<div
v-for="cc in paymentData.model.charge_concepts"
:key="cc.id"
class="flex justify-between text-xs text-gray-700 py-0.5"
>
<span>{{ cc.name }} <span class="text-gray-400">({{ cc.article }})</span></span>
<span class="font-medium">{{ formatAmount(cc.pivot?.override_amount) }}</span>
</div>
</div>
</div>
<!-- Formulario -->
<div class="bg-white rounded-xl shadow p-6">
<h2 class="text-base font-bold text-gray-700 mb-4">Datos para tu factura</h2>
<form class="space-y-4" @submit.prevent="submitRequest">
<!-- RFC -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">
RFC <span class="text-red-500">*</span>
</label>
<input
v-model="form.rfc"
type="text"
maxlength="13"
placeholder="Ej. XAXX010101000"
class="w-full border rounded-lg px-3 py-2 text-sm font-mono uppercase focus:outline-none focus:ring-2 focus:ring-[#621134]/40"
:class="serverErrors.rfc ? 'border-red-400' : 'border-gray-300'"
/>
<p v-if="serverErrors.rfc" class="text-xs text-red-500 mt-1">{{ serverErrors.rfc }}</p>
</div>
<!-- Email -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">
Correo electrónico <span class="text-red-500">*</span>
</label>
<input
v-model="form.email"
type="email"
placeholder="tucorreo@ejemplo.com"
class="w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[#621134]/40"
:class="serverErrors.email ? 'border-red-400' : 'border-gray-300'"
/>
<p v-if="serverErrors.email" class="text-xs text-red-500 mt-1">{{ serverErrors.email }}</p>
</div>
<!-- Teléfono -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">
Teléfono celular
<span class="text-gray-400 font-normal text-xs">(opcional para recibir la factura por WhatsApp)</span>
</label>
<input
v-model="form.phone"
type="tel"
maxlength="10"
placeholder="10 dígitos"
class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[#621134]/40"
/>
</div>
<!-- Nombre -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">
Nombre completo <span class="text-gray-400 font-normal text-xs">(opcional)</span>
</label>
<input
v-model="form.name"
type="text"
placeholder="Como aparece en tu constancia fiscal"
class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[#621134]/40"
/>
</div>
<!-- Razón social -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">
Razón social <span class="text-gray-400 font-normal text-xs">(opcional, solo para personas morales)</span>
</label>
<input
v-model="form.business_name"
type="text"
placeholder="Nombre de la empresa"
class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[#621134]/40"
/>
</div>
<!-- Régimen fiscal -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">
Régimen fiscal <span class="text-red-500">*</span>
</label>
<select
class="w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[#621134]/40 bg-white"
:class="serverErrors.fiscal_regimen_id ? 'border-red-400' : 'border-gray-300'"
@change="onRegimeChange(fiscalRegimes.find(r => r.id == $event.target.value))"
>
<option value="">Selecciona tu régimen fiscal</option>
<option
v-for="regime in fiscalRegimes"
:key="regime.id"
:value="regime.id"
>
{{ regime.code }} {{ regime.name }}
</option>
</select>
<p v-if="serverErrors.fiscal_regimen_id" class="text-xs text-red-500 mt-1">{{ serverErrors.fiscal_regimen_id }}</p>
</div>
<!-- Uso del CFDI -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">
Uso del CFDI <span class="text-red-500">*</span>
</label>
<div v-if="loadingCfdi" class="text-sm text-gray-400 py-2">Cargando usos disponibles</div>
<select
v-else
class="w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[#621134]/40 bg-white"
:class="serverErrors.cfdi_use_id ? 'border-red-400' : 'border-gray-300'"
:disabled="!form.fiscal_regimen_id"
@change="onCfdiChange(cfdiUses.find(u => u.id == $event.target.value))"
>
<option value="">{{ form.fiscal_regimen_id ? 'Selecciona el uso…' : 'Primero selecciona el régimen' }}</option>
<option
v-for="use in cfdiUses"
:key="use.id"
:value="use.id"
>
{{ use.code }} {{ use.name }}
</option>
</select>
<p v-if="serverErrors.cfdi_use_id" class="text-xs text-red-500 mt-1">{{ serverErrors.cfdi_use_id }}</p>
</div>
<!-- Código postal fiscal -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">
Código postal fiscal <span class="text-red-500">*</span>
</label>
<input
v-model="form.fiscal_postal_code"
type="text"
maxlength="5"
placeholder="5 dígitos"
class="w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[#621134]/40"
:class="serverErrors.fiscal_postal_code ? 'border-red-400' : 'border-gray-300'"
/>
<p v-if="serverErrors.fiscal_postal_code" class="text-xs text-red-500 mt-1">{{ serverErrors.fiscal_postal_code }}</p>
</div>
<!-- Dirección -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">
Dirección <span class="text-gray-400 font-normal text-xs">(opcional)</span>
</label>
<input
v-model="form.address"
type="text"
placeholder="Calle, número, colonia"
class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[#621134]/40"
/>
</div>
<!-- Botón enviar -->
<div class="pt-2">
<button
type="submit"
class="w-full py-3 rounded-lg text-white font-semibold text-sm bg-[#621134] hover:bg-[#7a1540] disabled:opacity-50 transition-colors"
:disabled="submitting"
>
<span v-if="submitting">Enviando solicitud</span>
<span v-else>Solicitar mi factura</span>
</button>
</div>
</form>
</div>
<p class="text-xs text-center text-gray-400 pb-4">
Al enviar aceptas que tus datos sean utilizados únicamente para la emisión de la factura electrónica.
</p>
</template>
</div>
</main>
<!-- Pie -->
<footer class="bg-white border-t py-3 px-4 text-center text-xs text-gray-400">
H. Ayuntamiento de Comalcalco Dirección de Tránsito Municipal
</footer>
</div>
</template>

View File

@ -112,6 +112,16 @@ const router = createRouter({
component: () => import('@Pages/App/Checkout/Index.vue') component: () => import('@Pages/App/Checkout/Index.vue')
}, },
] ]
},
{
path: 'invoice-requests',
children: [
{
path: '',
name: 'invoice-request.index',
component: () => import('@Pages/App/InvoiceRequest/Index.vue')
}
]
} }
], ],
}, },
@ -221,6 +231,11 @@ const router = createRouter({
} }
] ]
}, },
{
path: '/factura/:qr_token',
name: 'invoice.request',
component: () => import('@Pages/Public/Invoice/Index.vue')
},
{ {
path: '/:pathMatch(.*)*', path: '/:pathMatch(.*)*',
name: '404', name: '404',