FEAT:Modulo de facturación
This commit is contained in:
parent
2b7b884794
commit
f53d0ff457
@ -4,12 +4,20 @@ import { useRouter } from 'vue-router';
|
||||
import useLoader from '@Stores/Loader';
|
||||
import { hasToken } from '@Services/Api';
|
||||
|
||||
const PUBLIC_PATHS = ['/factura/'];
|
||||
|
||||
/** Definidores */
|
||||
const router = useRouter();
|
||||
const loader = useLoader();
|
||||
|
||||
/** Ciclos */
|
||||
onMounted(() => {
|
||||
onMounted(async () => {
|
||||
await router.isReady();
|
||||
|
||||
const currentPath = router.currentRoute.value.path;
|
||||
|
||||
if (PUBLIC_PATHS.some(prefix => currentPath.startsWith(prefix))) return;
|
||||
|
||||
if(!hasToken()) {
|
||||
return router.push({ name: 'auth.index' })
|
||||
}
|
||||
|
||||
@ -59,6 +59,11 @@ onMounted(() => {
|
||||
name="Entrega de caja"
|
||||
to="checkout.index"
|
||||
/>
|
||||
<Link
|
||||
icon="request_quote"
|
||||
name="Solicitudes de Factura"
|
||||
to="invoice-request.index"
|
||||
/>
|
||||
</Section>
|
||||
<Section
|
||||
v-if="hasPermission('users.index')"
|
||||
|
||||
206
src/pages/App/InvoiceRequest/Index.vue
Normal file
206
src/pages/App/InvoiceRequest/Index.vue
Normal 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>
|
||||
356
src/pages/App/InvoiceRequest/Modal/Show.vue
Normal file
356
src/pages/App/InvoiceRequest/Modal/Show.vue
Normal 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>
|
||||
10
src/pages/App/InvoiceRequest/Module.js
Normal file
10
src/pages/App/InvoiceRequest/Module.js
Normal 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 };
|
||||
499
src/pages/Public/Invoice/Index.vue
Normal file
499
src/pages/Public/Invoice/Index.vue
Normal 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>
|
||||
@ -112,6 +112,16 @@ const router = createRouter({
|
||||
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(.*)*',
|
||||
name: '404',
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user